mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
373a1a60d8 | ||
|
|
f71f219187 | ||
|
|
77b7be03d8 | ||
|
|
9dd9ce8105 | ||
|
|
dd64931be8 | ||
|
|
ac234f288e | ||
|
|
81749dd89c | ||
|
|
beef086d4c | ||
|
|
cfd67398de |
@@ -36,6 +36,7 @@ import { useAuthStore } from './src/stores/auth-store';
|
||||
import { useCalendarStore } from './src/stores/calendar-store';
|
||||
import { useContactsStore } from './src/stores/contacts-store';
|
||||
import { useEmailStore } from './src/stores/email-store';
|
||||
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
|
||||
import { useSettingsStore } from './src/stores/settings-store';
|
||||
import { useLocaleStore } from './src/stores/locale-store';
|
||||
import { useNetworkStore } from './src/stores/network-store';
|
||||
@@ -88,6 +89,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
const mailboxes = useEmailStore((state) => state.mailboxes);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0;
|
||||
const hasCalendar = useHasCalendar();
|
||||
const hasContacts = useHasContacts();
|
||||
const hasFiles = useHasFiles();
|
||||
const disabledTabStyle = { opacity: 0.4 } as const;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: c.background }}>
|
||||
@@ -149,6 +154,13 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
component={CalendarScreen}
|
||||
options={{
|
||||
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
|
||||
tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle,
|
||||
tabBarAccessibilityLabel: hasCalendar ? 'Calendar' : 'Calendar (unavailable)',
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: (e) => {
|
||||
if (!hasCalendar) e.preventDefault();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
@@ -156,6 +168,13 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
component={ContactsScreen}
|
||||
options={{
|
||||
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
|
||||
tabBarItemStyle: hasContacts ? undefined : disabledTabStyle,
|
||||
tabBarAccessibilityLabel: hasContacts ? 'Contacts' : 'Contacts (unavailable)',
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: (e) => {
|
||||
if (!hasContacts) e.preventDefault();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
@@ -163,6 +182,13 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
component={FilesScreen}
|
||||
options={{
|
||||
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
|
||||
tabBarItemStyle: hasFiles ? undefined : disabledTabStyle,
|
||||
tabBarAccessibilityLabel: hasFiles ? 'Files' : 'Files (unavailable)',
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: (e) => {
|
||||
if (!hasFiles) e.preventDefault();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
|
||||
@@ -20,6 +20,7 @@ module.exports = {
|
||||
expo: {
|
||||
name: 'Bulwark Mobile',
|
||||
slug: 'bulwark-mobile',
|
||||
scheme: 'bulwarkmobile',
|
||||
version: VERSION,
|
||||
orientation: 'portrait',
|
||||
icon: './assets/icon.png',
|
||||
|
||||
Generated
+68
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.31",
|
||||
"version": "0.1.36",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.31",
|
||||
"version": "0.1.36",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -17,6 +17,8 @@
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-auth-session": "~7.0.8",
|
||||
"expo-crypto": "~15.0.7",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
@@ -25,6 +27,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",
|
||||
@@ -5203,6 +5206,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-auth-session": {
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.11.tgz",
|
||||
"integrity": "sha512-AhWtt/m9rb1Po77X/VBFbeE6UTgbm2vXP2iCblUSRsHCw2qD6lO0ulKUB8Xyxy9FtoI9yrNQ1iwCNgIIgo8VYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expo-application": "~7.0.8",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-crypto": "~15.0.9",
|
||||
"expo-linking": "~8.0.12",
|
||||
"expo-web-browser": "~15.0.11",
|
||||
"invariant": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-auth-session/node_modules/expo-application": {
|
||||
"version": "7.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz",
|
||||
"integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-constants": {
|
||||
"version": "18.0.13",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
||||
@@ -5217,6 +5247,18 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-crypto": {
|
||||
"version": "15.0.9",
|
||||
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.9.tgz",
|
||||
"integrity": "sha512-SNWKa2fXx7v9gkp1h/7nqXY5XN7qgNDn3yRc2aO0gWGbeMbvob/haMxxsPFe9f51aqH5NjNCqHf2kvLhvAd8KQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-document-picker": {
|
||||
"version": "14.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
|
||||
@@ -5266,6 +5308,20 @@
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-linking": {
|
||||
"version": "8.0.12",
|
||||
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.12.tgz",
|
||||
"integrity": "sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expo-constants": "~18.0.13",
|
||||
"invariant": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-localization": {
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz",
|
||||
@@ -5418,6 +5474,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",
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.31",
|
||||
"version": "0.1.36",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -23,6 +23,8 @@
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-auth-session": "~7.0.8",
|
||||
"expo-crypto": "~15.0.7",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
@@ -31,6 +33,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",
|
||||
|
||||
+169
-15
@@ -22,6 +22,55 @@ export async function getMailboxes(): Promise<Mailbox[]> {
|
||||
return res.methodResponses[0][1].list;
|
||||
}
|
||||
|
||||
// Variant that also returns the JMAP `state` token so callers can later issue
|
||||
// Mailbox/changes(sinceState=…) to fetch only what changed.
|
||||
export async function getMailboxesWithState(): Promise<{ list: Mailbox[]; state: string }> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Mailbox/get', { accountId }, '0']],
|
||||
);
|
||||
const body = res.methodResponses[0][1];
|
||||
return { list: body.list as Mailbox[], state: body.state as string };
|
||||
}
|
||||
|
||||
export async function getMailboxesByIds(ids: string[]): Promise<{ list: Mailbox[]; state: string }> {
|
||||
if (ids.length === 0) return { list: [], state: '' };
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Mailbox/get', { accountId, ids }, '0']],
|
||||
);
|
||||
const body = res.methodResponses[0][1];
|
||||
return { list: body.list as Mailbox[], state: body.state as string };
|
||||
}
|
||||
|
||||
export interface MailboxChangesResult {
|
||||
oldState: string;
|
||||
newState: string;
|
||||
hasMoreChanges: boolean;
|
||||
created: string[];
|
||||
updated: string[];
|
||||
destroyed: string[];
|
||||
}
|
||||
|
||||
// Returns null when the server can't compute the diff (typically
|
||||
// `cannotCalculateChanges`); callers should fall back to a full Mailbox/get.
|
||||
export async function getMailboxChanges(sinceState: string): Promise<MailboxChangesResult | null> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Mailbox/changes', { accountId, sinceState }, '0']],
|
||||
);
|
||||
const [name, body] = res.methodResponses[0];
|
||||
if (name === 'error') return null;
|
||||
return {
|
||||
oldState: body.oldState as string,
|
||||
newState: body.newState as string,
|
||||
hasMoreChanges: Boolean(body.hasMoreChanges),
|
||||
created: (body.created as string[]) ?? [],
|
||||
updated: (body.updated as string[]) ?? [],
|
||||
destroyed: (body.destroyed as string[]) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function createMailbox(
|
||||
data: { name: string; parentId?: string | null },
|
||||
): Promise<string> {
|
||||
@@ -83,6 +132,22 @@ export async function deleteMailbox(id: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function buildMailboxQueryFilter(
|
||||
mailboxId: string,
|
||||
userFilter: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> {
|
||||
const inMailbox = { inMailbox: mailboxId };
|
||||
// JMAP filters are either a FilterCondition or a FilterOperator (operator +
|
||||
// conditions) — never both. Spreading a FilterOperator next to `inMailbox`
|
||||
// produces a hybrid object that servers reduce to the FilterCondition,
|
||||
// silently dropping the operator's conditions (e.g. the "unread" toggle).
|
||||
if (!userFilter || Object.keys(userFilter).length === 0) return inMailbox;
|
||||
if ('operator' in userFilter) {
|
||||
return { operator: 'AND', conditions: [inMailbox, userFilter] };
|
||||
}
|
||||
return { ...inMailbox, ...userFilter };
|
||||
}
|
||||
|
||||
export async function queryEmails(
|
||||
mailboxId: string,
|
||||
options?: {
|
||||
@@ -91,20 +156,9 @@ export async function queryEmails(
|
||||
sort?: Array<{ property: string; isAscending: boolean }>;
|
||||
filter?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<{ ids: string[]; total: number }> {
|
||||
): Promise<{ ids: string[]; total: number; queryState?: string }> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const inMailbox = { inMailbox: mailboxId };
|
||||
const userFilter = options?.filter;
|
||||
// JMAP filters are either a FilterCondition or a FilterOperator (operator +
|
||||
// conditions) — never both. Spreading a FilterOperator next to `inMailbox`
|
||||
// produces a hybrid object that servers reduce to the FilterCondition,
|
||||
// silently dropping the operator's conditions (e.g. the "unread" toggle).
|
||||
const filter =
|
||||
!userFilter || Object.keys(userFilter).length === 0
|
||||
? inMailbox
|
||||
: 'operator' in userFilter
|
||||
? { operator: 'AND', conditions: [inMailbox, userFilter] }
|
||||
: { ...inMailbox, ...userFilter };
|
||||
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
|
||||
const res = await jmapClient.request([
|
||||
['Email/query', {
|
||||
accountId,
|
||||
@@ -115,9 +169,56 @@ export async function queryEmails(
|
||||
calculateTotal: true,
|
||||
}, '0'],
|
||||
]);
|
||||
const body = res.methodResponses[0][1];
|
||||
return {
|
||||
ids: res.methodResponses[0][1].ids,
|
||||
total: res.methodResponses[0][1].total,
|
||||
ids: body.ids,
|
||||
total: body.total,
|
||||
queryState: body.queryState as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export interface EmailQueryChangesResult {
|
||||
oldQueryState: string;
|
||||
newQueryState: string;
|
||||
total: number;
|
||||
removed: string[];
|
||||
added: Array<{ id: string; index: number }>;
|
||||
}
|
||||
|
||||
// Run Email/queryChanges for the standard "by receivedAt desc, in this mailbox"
|
||||
// query. Returns null when the server replies with `cannotCalculateChanges`
|
||||
// (or any other error) — caller should fall back to a fresh Email/query.
|
||||
export async function getEmailQueryChanges(
|
||||
mailboxId: string,
|
||||
sinceQueryState: string,
|
||||
options?: {
|
||||
sort?: Array<{ property: string; isAscending: boolean }>;
|
||||
filter?: Record<string, unknown>;
|
||||
upToId?: string;
|
||||
maxChanges?: number;
|
||||
},
|
||||
): Promise<EmailQueryChangesResult | null> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
|
||||
const args: Record<string, unknown> = {
|
||||
accountId,
|
||||
filter,
|
||||
sort: options?.sort ?? [{ property: 'receivedAt', isAscending: false }],
|
||||
sinceQueryState,
|
||||
calculateTotal: true,
|
||||
};
|
||||
if (options?.upToId) args.upToId = options.upToId;
|
||||
if (options?.maxChanges) args.maxChanges = options.maxChanges;
|
||||
|
||||
const res = await jmapClient.request([['Email/queryChanges', args, '0']]);
|
||||
const [name, body] = res.methodResponses[0];
|
||||
if (name === 'error') return null;
|
||||
return {
|
||||
oldQueryState: body.oldQueryState as string,
|
||||
newQueryState: body.newQueryState as string,
|
||||
total: (body.total as number) ?? 0,
|
||||
removed: (body.removed as string[]) ?? [],
|
||||
added: (body.added as Array<{ id: string; index: number }>) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,6 +230,59 @@ export async function getEmails(ids: string[]): Promise<Email[]> {
|
||||
return res.methodResponses[0][1].list;
|
||||
}
|
||||
|
||||
// Returns the Email/get response with the JMAP `state` token. Used by the
|
||||
// store so we can later issue Email/changes(sinceState=…) for incremental
|
||||
// updates instead of re-fetching the full list.
|
||||
export async function getEmailsWithState(ids: string[]): Promise<{ list: Email[]; state: string }> {
|
||||
if (ids.length === 0) {
|
||||
// Email/get with an empty id list still returns a state token; useful for
|
||||
// priming the store after an empty mailbox query.
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Email/get', { accountId, ids: [], properties: EMAIL_LIST_PROPERTIES }, '0'],
|
||||
]);
|
||||
const body = res.methodResponses[0][1];
|
||||
return { list: [], state: body.state as string };
|
||||
}
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Email/get', { accountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0'],
|
||||
]);
|
||||
const body = res.methodResponses[0][1];
|
||||
return { list: body.list as Email[], state: body.state as string };
|
||||
}
|
||||
|
||||
export interface EmailChangesResult {
|
||||
oldState: string;
|
||||
newState: string;
|
||||
hasMoreChanges: boolean;
|
||||
created: string[];
|
||||
updated: string[];
|
||||
destroyed: string[];
|
||||
}
|
||||
|
||||
// Returns null when the server replies with `cannotCalculateChanges` or any
|
||||
// other error response; caller should treat that as "rebuild from scratch".
|
||||
export async function getEmailChanges(
|
||||
sinceState: string,
|
||||
maxChanges?: number,
|
||||
): Promise<EmailChangesResult | null> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const args: Record<string, unknown> = { accountId, sinceState };
|
||||
if (maxChanges) args.maxChanges = maxChanges;
|
||||
const res = await jmapClient.request([['Email/changes', args, '0']]);
|
||||
const [name, body] = res.methodResponses[0];
|
||||
if (name === 'error') return null;
|
||||
return {
|
||||
oldState: body.oldState as string,
|
||||
newState: body.newState as string,
|
||||
hasMoreChanges: Boolean(body.hasMoreChanges),
|
||||
created: (body.created as string[]) ?? [],
|
||||
updated: (body.updated as string[]) ?? [],
|
||||
destroyed: (body.destroyed as string[]) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFullEmail(id: string): Promise<Email> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
|
||||
+152
-12
@@ -8,6 +8,11 @@ import type {
|
||||
import { CAPABILITIES } from './types';
|
||||
import { generateAccountId } from '../lib/account-utils';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
import { refreshAccessToken, type OAuthTokens } from '../lib/oauth';
|
||||
|
||||
// Refresh OAuth access tokens this many ms before they actually expire so
|
||||
// in-flight requests don't race the expiry window.
|
||||
const TOKEN_REFRESH_LEEWAY_MS = 60_000;
|
||||
|
||||
const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
|
||||
const CREDENTIALS_PREFIX = 'jmap_credentials__';
|
||||
@@ -22,6 +27,12 @@ export interface StoredCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
accessToken?: string;
|
||||
// OAuth-only — present when credentials originate from the OAuth login
|
||||
// flow rather than password auth.
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
tokenEndpoint?: string;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
export class JMAPClient {
|
||||
@@ -93,6 +104,115 @@ export class JMAPClient {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
// OAuth login. Fetches the JMAP session to discover the authenticated
|
||||
// username from the session.username field, persists credentials under that
|
||||
// username's account id, and returns both the session and the resolved
|
||||
// username so the auth-store can register the account.
|
||||
async connectWithOAuth(
|
||||
serverUrl: string,
|
||||
tokens: OAuthTokens,
|
||||
): Promise<{ session: JMAPSession; username: string; accountId: string }> {
|
||||
const baseUrl = serverUrl.replace(/\/+$/, '');
|
||||
this.credentials = {
|
||||
serverUrl: baseUrl,
|
||||
username: '',
|
||||
password: '',
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
expiresAt: tokens.expiresAt,
|
||||
tokenEndpoint: tokens.tokenEndpoint,
|
||||
clientId: tokens.clientId,
|
||||
};
|
||||
|
||||
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
||||
this._accountId = this.resolveAccountId(this.session);
|
||||
|
||||
// The JMAP session document carries the authenticated user's identifier
|
||||
// — use it as the username so per-account storage keys are stable across
|
||||
// restarts and OAuth re-logins.
|
||||
const username = this.session.username || tokens.accessToken.slice(0, 8);
|
||||
this.credentials.username = username;
|
||||
|
||||
const accountId = generateAccountId(username, baseUrl);
|
||||
await SecureStore.setItemAsync(
|
||||
credentialsKey(accountId),
|
||||
JSON.stringify(this.credentials),
|
||||
);
|
||||
|
||||
return { session: this.session, username, accountId };
|
||||
}
|
||||
|
||||
// Replace the access token (and possibly refresh token) on the in-memory
|
||||
// credentials and re-persist. Used both by the proactive expiry check and
|
||||
// by the reactive 401-retry path.
|
||||
private async persistRefreshedTokens(next: OAuthTokens): Promise<void> {
|
||||
if (!this.credentials) return;
|
||||
this.credentials = {
|
||||
...this.credentials,
|
||||
accessToken: next.accessToken,
|
||||
refreshToken: next.refreshToken ?? this.credentials.refreshToken,
|
||||
expiresAt: next.expiresAt,
|
||||
tokenEndpoint: next.tokenEndpoint,
|
||||
clientId: next.clientId,
|
||||
};
|
||||
const accountId = generateAccountId(
|
||||
this.credentials.username,
|
||||
this.credentials.serverUrl,
|
||||
);
|
||||
await SecureStore.setItemAsync(
|
||||
credentialsKey(accountId),
|
||||
JSON.stringify(this.credentials),
|
||||
);
|
||||
}
|
||||
|
||||
private hasRefreshableToken(): boolean {
|
||||
return Boolean(this.credentials?.refreshToken && this.credentials.tokenEndpoint && this.credentials.clientId);
|
||||
}
|
||||
|
||||
private currentOAuthTokens(): OAuthTokens | null {
|
||||
if (!this.credentials?.accessToken || !this.credentials.tokenEndpoint || !this.credentials.clientId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
accessToken: this.credentials.accessToken,
|
||||
refreshToken: this.credentials.refreshToken,
|
||||
expiresAt: this.credentials.expiresAt,
|
||||
tokenEndpoint: this.credentials.tokenEndpoint,
|
||||
clientId: this.credentials.clientId,
|
||||
};
|
||||
}
|
||||
|
||||
// Refresh proactively when we're within the leeway window of expiry. Quiet
|
||||
// no-op for password credentials or when the token is still fresh.
|
||||
private async ensureFreshToken(): Promise<void> {
|
||||
const tokens = this.currentOAuthTokens();
|
||||
if (!tokens) return;
|
||||
if (!this.hasRefreshableToken()) return;
|
||||
if (tokens.expiresAt == null) return;
|
||||
if (tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return;
|
||||
try {
|
||||
const next = await refreshAccessToken(tokens);
|
||||
await this.persistRefreshedTokens(next);
|
||||
} catch {
|
||||
// Surface as AuthenticationError on the next 401; refresh may be
|
||||
// temporarily failing (network) and that path already retries.
|
||||
}
|
||||
}
|
||||
|
||||
// Reactive refresh: called after a 401 to attempt a single retry with a
|
||||
// freshly-minted access token. Returns true if a new token was obtained.
|
||||
private async forceRefreshToken(): Promise<boolean> {
|
||||
const tokens = this.currentOAuthTokens();
|
||||
if (!tokens || !this.hasRefreshableToken()) return false;
|
||||
try {
|
||||
const next = await refreshAccessToken(tokens);
|
||||
await this.persistRefreshedTokens(next);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy single-slot restore - kept for backward-compat tests. New code
|
||||
// should use loadAccount(accountId) driven by the account registry.
|
||||
async restoreSession(): Promise<boolean> {
|
||||
@@ -235,13 +355,23 @@ export class JMAPClient {
|
||||
|
||||
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
|
||||
const url = `${baseUrl}/.well-known/jmap`;
|
||||
const response = await secureFetch(url, {
|
||||
await this.ensureFreshToken();
|
||||
let response = await secureFetch(url, {
|
||||
headers: {
|
||||
Authorization: this.authHeader,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 401 && (await this.forceRefreshToken())) {
|
||||
response = await secureFetch(url, {
|
||||
headers: {
|
||||
Authorization: this.authHeader,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new AuthenticationError('Invalid credentials');
|
||||
}
|
||||
@@ -280,14 +410,21 @@ export class JMAPClient {
|
||||
methodCalls,
|
||||
};
|
||||
|
||||
const response = await secureFetch(this.session.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: this.authHeader,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
await this.ensureFreshToken();
|
||||
const doFetch = () =>
|
||||
secureFetch(this.session!.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: this.authHeader,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
let response = await doFetch();
|
||||
if (response.status === 401 && (await this.forceRefreshToken())) {
|
||||
response = await doFetch();
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new AuthenticationError('Session expired');
|
||||
@@ -335,9 +472,12 @@ export class JMAPClient {
|
||||
|
||||
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer> {
|
||||
const url = this.getBlobDownloadUrl(blobId, name, type);
|
||||
const response = await secureFetch(url, {
|
||||
headers: { Authorization: this.authHeader },
|
||||
});
|
||||
await this.ensureFreshToken();
|
||||
const doFetch = () => secureFetch(url, { headers: { Authorization: this.authHeader } });
|
||||
let response = await doFetch();
|
||||
if (response.status === 401 && (await this.forceRefreshToken())) {
|
||||
response = await doFetch();
|
||||
}
|
||||
if (response.status === 401) throw new AuthenticationError('Session expired');
|
||||
if (!response.ok) throw new Error(`Failed to fetch blob: ${response.status}`);
|
||||
return response.arrayBuffer();
|
||||
|
||||
@@ -9,6 +9,10 @@ export interface JMAPSession {
|
||||
accounts: Record<string, JMAPAccountInfo>;
|
||||
capabilities: Record<string, unknown>;
|
||||
state: string;
|
||||
// RFC 8620 §2 — authenticated user identifier. Optional because not all
|
||||
// servers populate it; OAuth login uses it to derive the account label
|
||||
// when there's no password username to fall back on.
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface JMAPAccountInfo {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { CAPABILITIES } from '../api/types';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
|
||||
// When the session is null (cold start, offline restore) we assume features
|
||||
// are available so they don't flicker off mid-restore. Once the live session
|
||||
// arrives, the real capability set takes over.
|
||||
function sessionHasCapability(capability: string): boolean {
|
||||
const session = useAuthStore.getState().session;
|
||||
if (!session) return true;
|
||||
return capability in session.capabilities;
|
||||
}
|
||||
|
||||
export function hasCalendarCapability(): boolean {
|
||||
return sessionHasCapability(CAPABILITIES.CALENDARS);
|
||||
}
|
||||
|
||||
export function hasContactsCapability(): boolean {
|
||||
return sessionHasCapability(CAPABILITIES.CONTACTS);
|
||||
}
|
||||
|
||||
export function hasFilesCapability(): boolean {
|
||||
return sessionHasCapability(CAPABILITIES.FILES);
|
||||
}
|
||||
|
||||
export function useHasCalendar(): boolean {
|
||||
return useAuthStore((s) => (s.session ? CAPABILITIES.CALENDARS in s.session.capabilities : true));
|
||||
}
|
||||
|
||||
export function useHasContacts(): boolean {
|
||||
return useAuthStore((s) => (s.session ? CAPABILITIES.CONTACTS in s.session.capabilities : true));
|
||||
}
|
||||
|
||||
export function useHasFiles(): boolean {
|
||||
return useAuthStore((s) => (s.session ? CAPABILITIES.FILES in s.session.capabilities : true));
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import * as AuthSession from 'expo-auth-session';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { secureFetch } from './client-cert';
|
||||
|
||||
// OAuth client ID registered (or accepted unregistered, when the server has
|
||||
// requireClientRegistration=false like Stalwart 0.16) for the mobile app.
|
||||
export const OAUTH_CLIENT_ID = 'bulwark-mobile';
|
||||
|
||||
// offline_access is required for Stalwart to return a refresh_token; without
|
||||
// it the session dies as soon as the access token expires.
|
||||
export const OAUTH_SCOPES = ['openid', 'email', 'profile', 'offline_access'];
|
||||
|
||||
export interface OAuthDiscovery {
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
revocation_endpoint?: string;
|
||||
}
|
||||
|
||||
export interface OAuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number; // epoch ms
|
||||
tokenEndpoint: string;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export class OAuthError extends Error {}
|
||||
export class OAuthCancelledError extends OAuthError {
|
||||
constructor() {
|
||||
super('OAuth cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverOAuth(serverUrl: string): Promise<OAuthDiscovery> {
|
||||
const baseUrl = serverUrl.replace(/\/+$/, '');
|
||||
const urls = [
|
||||
`${baseUrl}/.well-known/oauth-authorization-server`,
|
||||
`${baseUrl}/.well-known/openid-configuration`,
|
||||
];
|
||||
const errors: string[] = [];
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const res = await secureFetch(url, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) {
|
||||
errors.push(`${url} → HTTP ${res.status}`);
|
||||
continue;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.authorization_endpoint && data.token_endpoint) {
|
||||
return {
|
||||
authorization_endpoint: data.authorization_endpoint,
|
||||
token_endpoint: data.token_endpoint,
|
||||
revocation_endpoint: data.revocation_endpoint,
|
||||
};
|
||||
}
|
||||
errors.push(`${url} missing endpoints`);
|
||||
} catch (err) {
|
||||
errors.push(`${url}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
throw new OAuthError(`OAuth discovery failed: ${errors.join('; ')}`);
|
||||
}
|
||||
|
||||
// Returns the redirect URI the browser will land on after the user signs in.
|
||||
// For dev (Expo Go) this is the auth.expo.io proxy; for standalone/prebuild
|
||||
// builds this is the app's custom scheme (bulwarkmobile://auth/callback).
|
||||
function getRedirectUri(): string {
|
||||
return AuthSession.makeRedirectUri({ scheme: 'bulwarkmobile', path: 'auth/callback' });
|
||||
}
|
||||
|
||||
export async function runOAuthFlow(serverUrl: string): Promise<OAuthTokens> {
|
||||
const discovery = await discoverOAuth(serverUrl);
|
||||
const redirectUri = getRedirectUri();
|
||||
|
||||
const request = new AuthSession.AuthRequest({
|
||||
clientId: OAUTH_CLIENT_ID,
|
||||
scopes: OAUTH_SCOPES,
|
||||
redirectUri,
|
||||
responseType: AuthSession.ResponseType.Code,
|
||||
usePKCE: true,
|
||||
});
|
||||
|
||||
// promptAsync() opens the system browser tab and resolves when the OAuth
|
||||
// server redirects back to the app via the custom scheme.
|
||||
const result = await request.promptAsync({
|
||||
authorizationEndpoint: discovery.authorization_endpoint,
|
||||
});
|
||||
|
||||
if (result.type === 'cancel' || result.type === 'dismiss') {
|
||||
throw new OAuthCancelledError();
|
||||
}
|
||||
if (result.type !== 'success') {
|
||||
const msg =
|
||||
result.type === 'error' && result.error
|
||||
? result.error.description || result.error.message || result.type
|
||||
: result.type;
|
||||
throw new OAuthError(`Authorization failed: ${msg}`);
|
||||
}
|
||||
const code = result.params.code;
|
||||
if (!code) throw new OAuthError('Authorization response missing code');
|
||||
|
||||
// Exchange the authorization code for tokens. Stalwart returns a JSON body
|
||||
// with access_token, refresh_token, expires_in.
|
||||
const tokenResult = await AuthSession.exchangeCodeAsync(
|
||||
{
|
||||
clientId: OAUTH_CLIENT_ID,
|
||||
code,
|
||||
redirectUri,
|
||||
extraParams: request.codeVerifier ? { code_verifier: request.codeVerifier } : undefined,
|
||||
},
|
||||
{ tokenEndpoint: discovery.token_endpoint },
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken: tokenResult.accessToken,
|
||||
refreshToken: tokenResult.refreshToken,
|
||||
expiresAt:
|
||||
tokenResult.expiresIn != null
|
||||
? Date.now() + tokenResult.expiresIn * 1000
|
||||
: undefined,
|
||||
tokenEndpoint: discovery.token_endpoint,
|
||||
clientId: OAUTH_CLIENT_ID,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(
|
||||
tokens: OAuthTokens,
|
||||
): Promise<OAuthTokens> {
|
||||
if (!tokens.refreshToken) {
|
||||
throw new OAuthError('No refresh token available');
|
||||
}
|
||||
const refreshed = await AuthSession.refreshAsync(
|
||||
{ clientId: tokens.clientId, refreshToken: tokens.refreshToken },
|
||||
{ tokenEndpoint: tokens.tokenEndpoint },
|
||||
);
|
||||
return {
|
||||
accessToken: refreshed.accessToken,
|
||||
// Rotated refresh tokens replace the previous one; some IdPs omit it and
|
||||
// expect the client to reuse the original.
|
||||
refreshToken: refreshed.refreshToken ?? tokens.refreshToken,
|
||||
expiresAt:
|
||||
refreshed.expiresIn != null
|
||||
? Date.now() + refreshed.expiresIn * 1000
|
||||
: undefined,
|
||||
tokenEndpoint: tokens.tokenEndpoint,
|
||||
clientId: tokens.clientId,
|
||||
};
|
||||
}
|
||||
|
||||
// Required to dismiss the in-app browser tab once the OAuth redirect fires.
|
||||
// Safe to call once at module load; expo-web-browser exposes it via a method
|
||||
// that no-ops on platforms where it isn't needed.
|
||||
export function warmUpAuthBrowser(): void {
|
||||
void WebBrowser.warmUpAsync().catch(() => undefined);
|
||||
}
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
@@ -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 loginOAuth = useAuthStore((state) => state.loginOAuth);
|
||||
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 canOAuth = Boolean(serverUrl.trim());
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!canSubmit) {
|
||||
@@ -44,6 +46,24 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
}
|
||||
};
|
||||
|
||||
const handleOAuth = async () => {
|
||||
if (!canOAuth) return;
|
||||
try {
|
||||
const before = useAuthStore.getState().isAuthenticated;
|
||||
await loginOAuth(serverUrl.trim(), { addAccount: isAddMode });
|
||||
// loginOAuth swallows OAuthCancelledError 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) {
|
||||
@@ -151,8 +171,15 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
|
||||
{/* OAuth buttons */}
|
||||
<View style={styles.oauthRow}>
|
||||
<Button variant="outline" size="md" disabled>
|
||||
SSO Provider
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
onPress={() => {
|
||||
void handleOAuth();
|
||||
}}
|
||||
disabled={!canOAuth || isLoading}
|
||||
>
|
||||
Sign in with OAuth
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -36,6 +36,7 @@ import { LanguageSettings } from '../components/settings/LanguageSettings';
|
||||
import { ComposingSettings } from '../components/settings/ComposingSettings';
|
||||
import { LayoutSettings } from '../components/settings/LayoutSettings';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useHasCalendar, useHasContacts, useHasFiles } from '../lib/capabilities';
|
||||
|
||||
type Tab =
|
||||
| 'account' | 'language' | 'notifications'
|
||||
@@ -155,6 +156,16 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
// Subscribe to locale so labels re-render when the user picks a different language.
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const hasCalendar = useHasCalendar();
|
||||
const hasContacts = useHasContacts();
|
||||
const hasFiles = useHasFiles();
|
||||
const unavailableTabs = React.useMemo(() => {
|
||||
const set = new Set<Tab>();
|
||||
if (!hasCalendar) set.add('calendar');
|
||||
if (!hasContacts) set.add('contacts');
|
||||
if (!hasFiles) set.add('files');
|
||||
return set;
|
||||
}, [hasCalendar, hasContacts, hasFiles]);
|
||||
const groupedTabs = React.useMemo(() => {
|
||||
void locale; // dependency: re-translate on locale change
|
||||
return groupTabs().map((g) => ({
|
||||
@@ -178,6 +189,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
|
||||
const handleTabPress = (tab: TabDef) => {
|
||||
if (!tab.implemented) return;
|
||||
if (unavailableTabs.has(tab.id)) return;
|
||||
setSelectedTab(tab.id);
|
||||
onTabSelect?.(tab.id);
|
||||
};
|
||||
@@ -241,7 +253,13 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
|
||||
{group.items.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const disabled = !tab.implemented;
|
||||
const unavailable = unavailableTabs.has(tab.id);
|
||||
const disabled = !tab.implemented || unavailable;
|
||||
const badgeLabel = !tab.implemented
|
||||
? 'Not implemented'
|
||||
: unavailable
|
||||
? 'Unavailable'
|
||||
: null;
|
||||
return (
|
||||
<Pressable
|
||||
key={tab.id}
|
||||
@@ -269,9 +287,9 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{disabled ? (
|
||||
{badgeLabel ? (
|
||||
<View style={styles.notWorkingBadge}>
|
||||
<Text style={styles.notWorkingText}>Not implemented</Text>
|
||||
<Text style={styles.notWorkingText}>{badgeLabel}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ChevronRight size={16} color={c.mutedForeground} />
|
||||
|
||||
@@ -2,8 +2,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../api/email', () => ({
|
||||
getMailboxes: vi.fn(),
|
||||
// New helpers used by the incremental-sync path. Default behavior: behave
|
||||
// like a first-ever load — no prior state, full re-query expected.
|
||||
getMailboxesWithState: vi.fn(async () => ({ list: [], state: 'mb-state-0' })),
|
||||
getMailboxesByIds: vi.fn(async () => ({ list: [], state: 'mb-state-0' })),
|
||||
getMailboxChanges: vi.fn(async () => null),
|
||||
queryEmails: vi.fn(),
|
||||
getEmailQueryChanges: vi.fn(async () => null),
|
||||
getEmails: vi.fn(),
|
||||
getEmailsWithState: vi.fn(async () => ({ list: [], state: 'em-state-0' })),
|
||||
getEmailChanges: vi.fn(async () => null),
|
||||
getFullEmail: vi.fn(),
|
||||
setEmailKeywords: vi.fn(),
|
||||
moveEmail: vi.fn(),
|
||||
@@ -20,23 +28,54 @@ vi.mock('../settings-store', () => ({
|
||||
useSettingsStore: { getState: () => ({ archiveMode: 'single', emailsPerPage: 25 }) },
|
||||
}));
|
||||
|
||||
// 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.
|
||||
// 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,
|
||||
get: vi.fn(async () => null),
|
||||
put: vi.fn(async () => undefined),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// 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';
|
||||
|
||||
const mockGetMailboxes = emailApi.getMailboxes as ReturnType<typeof vi.fn>;
|
||||
const mockGetMailboxesWithState = emailApi.getMailboxesWithState as ReturnType<typeof vi.fn>;
|
||||
const mockQueryEmails = emailApi.queryEmails as ReturnType<typeof vi.fn>;
|
||||
const mockGetEmails = emailApi.getEmails as ReturnType<typeof vi.fn>;
|
||||
const mockGetEmailsWithState = emailApi.getEmailsWithState as ReturnType<typeof vi.fn>;
|
||||
const mockGetFullEmail = emailApi.getFullEmail as ReturnType<typeof vi.fn>;
|
||||
const mockSetKeywords = emailApi.setEmailKeywords as ReturnType<typeof vi.fn>;
|
||||
const mockMoveEmail = emailApi.moveEmail as ReturnType<typeof vi.fn>;
|
||||
@@ -46,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', () => {
|
||||
@@ -55,7 +98,7 @@ describe('email-store', () => {
|
||||
{ id: 'mb-1', name: 'Inbox', role: 'inbox' },
|
||||
{ id: 'mb-2', name: 'Sent', role: 'sent' },
|
||||
];
|
||||
mockGetMailboxes.mockResolvedValue(mailboxes);
|
||||
mockGetMailboxesWithState.mockResolvedValue({ list: mailboxes, state: 'mb-state-1' });
|
||||
|
||||
await useEmailStore.getState().fetchMailboxes();
|
||||
|
||||
@@ -63,7 +106,7 @@ describe('email-store', () => {
|
||||
});
|
||||
|
||||
it('should set error on failure', async () => {
|
||||
mockGetMailboxes.mockRejectedValue(new Error('Network error'));
|
||||
mockGetMailboxesWithState.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await useEmailStore.getState().fetchMailboxes();
|
||||
|
||||
@@ -73,12 +116,12 @@ describe('email-store', () => {
|
||||
|
||||
describe('selectMailbox', () => {
|
||||
it('should query and fetch emails for mailbox', async () => {
|
||||
mockQueryEmails.mockResolvedValue({ ids: ['e1', 'e2'], total: 2 });
|
||||
mockQueryEmails.mockResolvedValue({ ids: ['e1', 'e2'], total: 2, queryState: 'q-1' });
|
||||
const emails = [
|
||||
{ id: 'e1', subject: 'Email 1' },
|
||||
{ id: 'e2', subject: 'Email 2' },
|
||||
];
|
||||
mockGetEmails.mockResolvedValue(emails);
|
||||
mockGetEmailsWithState.mockResolvedValue({ list: emails, state: 'em-state-1' });
|
||||
|
||||
await useEmailStore.getState().selectMailbox('mb-1');
|
||||
|
||||
@@ -90,11 +133,14 @@ describe('email-store', () => {
|
||||
});
|
||||
|
||||
it('should handle empty mailbox', async () => {
|
||||
mockQueryEmails.mockResolvedValue({ ids: [], total: 0 });
|
||||
mockQueryEmails.mockResolvedValue({ ids: [], total: 0, queryState: 'q-empty' });
|
||||
|
||||
await useEmailStore.getState().selectMailbox('mb-empty');
|
||||
|
||||
expect(useEmailStore.getState().emails).toEqual([]);
|
||||
// First-time load with no ids hits getEmailsWithState only to prime
|
||||
// emailState (with an empty ids array). It should NOT call legacy
|
||||
// getEmails — that path is reserved for pagination/search.
|
||||
expect(mockGetEmails).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+110
-19
@@ -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 { runOAuthFlow, OAuthCancelledError } 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>;
|
||||
loginOAuth: (serverUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
logoutAll: () => Promise<void>;
|
||||
switchAccount: (accountId: string) => Promise<void>;
|
||||
@@ -46,8 +48,28 @@ export interface AuthState {
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
function resetFeatureStores(): void {
|
||||
useEmailStore.getState().reset();
|
||||
// Wipe ALL cached feature data for ALL accounts. Used for logoutAll where
|
||||
// the user is signing out of everything — we don't want stale snapshots
|
||||
// lingering on disk for accounts that no longer exist.
|
||||
function clearAllFeatureStores(): void {
|
||||
useEmailStore.getState().clearAllAccounts();
|
||||
useContactsStore.getState().reset();
|
||||
useCalendarStore.getState().reset();
|
||||
}
|
||||
|
||||
// Drop the named account from the email cache, then reset the (per-session,
|
||||
// not yet per-account) contacts and calendar stores. Used by logout when
|
||||
// signing one account out while others remain.
|
||||
function clearAccountFeatureStores(accountId: string | null): void {
|
||||
if (accountId) {
|
||||
useEmailStore.getState().removeAccount(accountId);
|
||||
} else {
|
||||
useEmailStore.getState().clearAllAccounts();
|
||||
}
|
||||
// Contacts and calendar stores aren't yet keyed by account — the safe
|
||||
// thing on logout is still to wipe them so the next account doesn't see
|
||||
// the previous user's data. Per-account caching for those stores is a
|
||||
// follow-up.
|
||||
useContactsStore.getState().reset();
|
||||
useCalendarStore.getState().reset();
|
||||
}
|
||||
@@ -108,11 +130,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
login: async (serverUrl, username, password, opts) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
// Adding an additional account - snapshot/reset feature stores so the
|
||||
// new account starts with a clean slate.
|
||||
// Adding an additional account - snapshot the current account away so
|
||||
// its cache survives, then reset the JMAP client for the new login.
|
||||
// Contacts/calendar are still single-bucket, so wipe those.
|
||||
if (opts?.addAccount && get().isAuthenticated) {
|
||||
jmapClient.reset();
|
||||
resetFeatureStores();
|
||||
useContactsStore.getState().reset();
|
||||
useCalendarStore.getState().reset();
|
||||
}
|
||||
|
||||
const session = await jmapClient.connect(serverUrl, username, password);
|
||||
@@ -129,6 +153,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
hasError: false,
|
||||
});
|
||||
accountStore.setActiveAccount(accountId);
|
||||
// Swap the email store's active view to the new account so the rest of
|
||||
// this function (and refetchFeatureStores) writes to the right bucket.
|
||||
useEmailStore.getState().setActiveAccount(accountId);
|
||||
|
||||
applyConnectedState(set, session, serverUrl.replace(/\/+$/, ''), username, accountId);
|
||||
} catch (err) {
|
||||
@@ -142,6 +169,51 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
loginOAuth: async (serverUrl, opts) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
if (opts?.addAccount && get().isAuthenticated) {
|
||||
jmapClient.reset();
|
||||
useContactsStore.getState().reset();
|
||||
useCalendarStore.getState().reset();
|
||||
}
|
||||
|
||||
const baseUrl = serverUrl.replace(/\/+$/, '');
|
||||
const tokens = await runOAuthFlow(baseUrl);
|
||||
const { session, username, accountId } = await jmapClient.connectWithOAuth(baseUrl, tokens);
|
||||
|
||||
const accountStore = useAccountStore.getState();
|
||||
accountStore.addAccount({
|
||||
serverUrl: baseUrl,
|
||||
username,
|
||||
displayName: username,
|
||||
email: username,
|
||||
lastLoginAt: Date.now(),
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
});
|
||||
accountStore.setActiveAccount(accountId);
|
||||
useEmailStore.getState().setActiveAccount(accountId);
|
||||
|
||||
applyConnectedState(set, session, baseUrl, username, accountId);
|
||||
} catch (err) {
|
||||
if (err instanceof OAuthCancelledError) {
|
||||
// User backed out of the browser tab — no error message, just stop
|
||||
// the spinner. This matches the common "I changed my mind" path.
|
||||
set({ isLoading: false, error: null });
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
err instanceof AuthenticationError
|
||||
? 'Authentication rejected by server'
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'OAuth login failed';
|
||||
set({ isLoading: false, error: message });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
const accountStore = useAccountStore.getState();
|
||||
const currentId = get().activeAccountId;
|
||||
@@ -164,7 +236,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
|
||||
jmapClient.reset();
|
||||
resetFeatureStores();
|
||||
clearAccountFeatureStores(currentId);
|
||||
|
||||
// Switch to next remaining account, if any
|
||||
const remaining = accountStore.accounts;
|
||||
@@ -198,7 +270,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
await teardownPushNotifications().catch(() => undefined);
|
||||
await jmapClient.clearAllCredentials(ids);
|
||||
jmapClient.reset();
|
||||
resetFeatureStores();
|
||||
clearAllFeatureStores();
|
||||
|
||||
for (const id of ids) accountStore.removeAccount(id);
|
||||
|
||||
@@ -225,19 +297,31 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
// Load the new account's session BEFORE clearing jmapClient or feature
|
||||
// stores. The previous order (reset → resetFeatureStores → loadAccount)
|
||||
// left jmapClient with no accountId during the loadAccount await, and
|
||||
// any fetch fired by a screen effect reacting to the emptied stores
|
||||
// (e.g. EmailListScreen retrying fetchMailboxes when mailboxes goes to
|
||||
// []) threw "Not authenticated - call connect() first". loadAccount
|
||||
// overwrites credentials/session/_accountId itself, so the pre-reset
|
||||
// is unnecessary.
|
||||
// Swap the email-store view to the new account *before* the network
|
||||
// round-trip. The previous account's data is tucked into its snapshot;
|
||||
// the new account's data (if previously cached) is restored to the
|
||||
// top-level fields so the EmailListScreen immediately shows the new
|
||||
// account's last-known mail instead of flashing empty. The network
|
||||
// refresh below applies incremental updates on top.
|
||||
useEmailStore.getState().setActiveAccount(accountId);
|
||||
|
||||
// Contacts and calendar stores aren't yet per-account, so they still
|
||||
// need a reset to avoid showing the previous account's data.
|
||||
useContactsStore.getState().reset();
|
||||
useCalendarStore.getState().reset();
|
||||
|
||||
// Load the new account's session. loadAccount overwrites
|
||||
// credentials/session/_accountId itself, so we don't need to reset
|
||||
// jmapClient first. If it fails, restore the previous active account
|
||||
// so we don't leave the user stranded on a half-switched state.
|
||||
const previousActive = get().activeAccountId;
|
||||
try {
|
||||
const ok = await jmapClient.loadAccount(accountId);
|
||||
if (!ok) {
|
||||
// Credentials missing - evict stale entry and surface error
|
||||
accountStore.removeAccount(accountId);
|
||||
useEmailStore.getState().removeAccount(accountId);
|
||||
if (previousActive) useEmailStore.getState().setActiveAccount(previousActive);
|
||||
set({ isLoading: false, error: 'Session expired for this account' });
|
||||
return;
|
||||
}
|
||||
@@ -245,11 +329,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
if (err instanceof AuthenticationError) {
|
||||
await jmapClient.clearAccountCredentials(accountId).catch(() => undefined);
|
||||
accountStore.removeAccount(accountId);
|
||||
useEmailStore.getState().removeAccount(accountId);
|
||||
if (previousActive) useEmailStore.getState().setActiveAccount(previousActive);
|
||||
set({ isLoading: false, error: 'Session expired for this account' });
|
||||
return;
|
||||
}
|
||||
// NetworkError or anything else - keep the previous active account
|
||||
// intact instead of stranding the user on a half-switched state.
|
||||
if (previousActive) useEmailStore.getState().setActiveAccount(previousActive);
|
||||
set({
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to switch account',
|
||||
@@ -257,10 +344,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Session is in place under the new account. Safe now to wipe the
|
||||
// previous account's cached feature data; the refetch below repopulates.
|
||||
resetFeatureStores();
|
||||
|
||||
accountStore.setActiveAccount(accountId);
|
||||
accountStore.updateAccount(accountId, {
|
||||
isConnected: true,
|
||||
@@ -320,11 +403,18 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
return false;
|
||||
}
|
||||
|
||||
// Point the email store at the target account before any await — so
|
||||
// the EmailListScreen, which re-renders the moment the persisted state
|
||||
// hydrates, sees the right account's cached emails instead of stale
|
||||
// data from a previous session.
|
||||
useEmailStore.getState().setActiveAccount(target.id);
|
||||
|
||||
try {
|
||||
const ok = await jmapClient.loadAccount(target.id);
|
||||
if (!ok) {
|
||||
// No stored credentials (or corrupt) — genuine logout.
|
||||
accountStore.removeAccount(target.id);
|
||||
useEmailStore.getState().removeAccount(target.id);
|
||||
set({ isLoading: false, hasRestoredSession: true });
|
||||
return false;
|
||||
}
|
||||
@@ -358,6 +448,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
// Server reachable but credentials rejected — drop them.
|
||||
await jmapClient.clearAccountCredentials(target.id).catch(() => undefined);
|
||||
accountStore.removeAccount(target.id);
|
||||
useEmailStore.getState().removeAccount(target.id);
|
||||
set({ isLoading: false, hasRestoredSession: true, error: 'Session expired' });
|
||||
return false;
|
||||
}
|
||||
|
||||
+562
-80
@@ -5,8 +5,14 @@ import type { Email, Mailbox, StateChange } from '../api/types';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import {
|
||||
getMailboxes as fetchMailboxes,
|
||||
getMailboxesWithState,
|
||||
getMailboxesByIds,
|
||||
getMailboxChanges,
|
||||
queryEmails,
|
||||
getEmailQueryChanges,
|
||||
getEmails as fetchEmails,
|
||||
getEmailsWithState,
|
||||
getEmailChanges,
|
||||
getFullEmail,
|
||||
setEmailKeywords,
|
||||
moveEmail,
|
||||
@@ -16,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;
|
||||
@@ -43,17 +67,56 @@ export interface UndoEntry {
|
||||
items: Array<{ email: Email; originalMailboxIds: Record<string, boolean> }>;
|
||||
}
|
||||
|
||||
export interface EmailState {
|
||||
// Cached emails for one mailbox (the base view: no search query, no filters).
|
||||
// `queryState` is the JMAP queryState for the matching Email/query, used to
|
||||
// drive Email/queryChanges on the next refresh.
|
||||
interface MailboxSnapshot {
|
||||
emails: Email[];
|
||||
total: number;
|
||||
queryState?: string;
|
||||
}
|
||||
|
||||
// Everything we cache for one account so switching accounts can restore the
|
||||
// previous view instantly instead of going through a network round-trip.
|
||||
interface AccountSnapshot {
|
||||
mailboxes: Mailbox[];
|
||||
mailboxState?: string; // JMAP Mailbox state (drives Mailbox/changes)
|
||||
emailState?: string; // JMAP Email state (drives Email/changes)
|
||||
currentMailboxId: string | null;
|
||||
mailboxSnapshots: Record<string, MailboxSnapshot>;
|
||||
}
|
||||
|
||||
export interface EmailState {
|
||||
// ── Per-account persisted caches ──────────────────────────────
|
||||
// accountSnapshots is the source of truth for accounts the user is *not*
|
||||
// currently viewing. The active account's data lives in the top-level
|
||||
// fields below (`mailboxes`, `mailboxSnapshots`, `mailboxState`, `emailState`,
|
||||
// `currentMailboxId`, `emails`, `totalEmails`, `queryState`) so consumers
|
||||
// keep reading the same shape they always have.
|
||||
accountSnapshots: Record<string, AccountSnapshot>;
|
||||
activeAccountId: string | null;
|
||||
|
||||
// ── Active view (the currently-shown account/mailbox) ─────────
|
||||
mailboxes: Mailbox[];
|
||||
mailboxState?: string;
|
||||
emailState?: string;
|
||||
currentMailboxId: string | null;
|
||||
mailboxSnapshots: Record<string, MailboxSnapshot>;
|
||||
emails: Email[];
|
||||
totalEmails: number;
|
||||
queryState?: string; // queryState for the currently-shown mailbox
|
||||
|
||||
// ── UI state (not persisted, not per-account) ─────────────────
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
searchQuery: string;
|
||||
filters: EmailFilters;
|
||||
pendingUndo: UndoEntry | null;
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────
|
||||
setActiveAccount: (accountId: string | null) => void;
|
||||
removeAccount: (accountId: string) => void;
|
||||
clearAllAccounts: () => void;
|
||||
fetchMailboxes: () => Promise<void>;
|
||||
selectMailbox: (mailboxId: string) => Promise<void>;
|
||||
loadMoreEmails: () => Promise<void>;
|
||||
@@ -115,103 +178,360 @@ function buildJmapFilter(
|
||||
return { operator: 'AND', conditions };
|
||||
}
|
||||
|
||||
// True when the user has no search/filters active. Only in this case do we
|
||||
// touch the per-mailbox snapshot cache or use Email/queryChanges — once a
|
||||
// filter is in play, the queryState belongs to a different query and the
|
||||
// cached list no longer represents what's on screen.
|
||||
function isBaseView(searchQuery: string, filters: EmailFilters): boolean {
|
||||
return !searchQuery.trim() && Object.keys(filters).length === 0;
|
||||
}
|
||||
|
||||
function snapshotFromActive(state: EmailState): AccountSnapshot {
|
||||
// Persist the currently-visible mailbox into its snapshot before tucking
|
||||
// the whole account away.
|
||||
let mailboxSnapshots = state.mailboxSnapshots;
|
||||
if (state.currentMailboxId && isBaseView(state.searchQuery, state.filters)) {
|
||||
mailboxSnapshots = {
|
||||
...mailboxSnapshots,
|
||||
[state.currentMailboxId]: {
|
||||
emails: state.emails,
|
||||
total: state.totalEmails,
|
||||
queryState: state.queryState,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
mailboxes: state.mailboxes,
|
||||
mailboxState: state.mailboxState,
|
||||
emailState: state.emailState,
|
||||
currentMailboxId: state.currentMailboxId,
|
||||
mailboxSnapshots,
|
||||
};
|
||||
}
|
||||
|
||||
function viewFromSnapshot(snap: AccountSnapshot | null): {
|
||||
mailboxes: Mailbox[];
|
||||
mailboxState?: string;
|
||||
emailState?: string;
|
||||
currentMailboxId: string | null;
|
||||
mailboxSnapshots: Record<string, MailboxSnapshot>;
|
||||
emails: Email[];
|
||||
totalEmails: number;
|
||||
queryState?: string;
|
||||
} {
|
||||
if (!snap) {
|
||||
return {
|
||||
mailboxes: [],
|
||||
mailboxState: undefined,
|
||||
emailState: undefined,
|
||||
currentMailboxId: null,
|
||||
mailboxSnapshots: {},
|
||||
emails: [],
|
||||
totalEmails: 0,
|
||||
queryState: undefined,
|
||||
};
|
||||
}
|
||||
const mailboxSnap = snap.currentMailboxId
|
||||
? snap.mailboxSnapshots[snap.currentMailboxId]
|
||||
: undefined;
|
||||
return {
|
||||
mailboxes: snap.mailboxes,
|
||||
mailboxState: snap.mailboxState,
|
||||
emailState: snap.emailState,
|
||||
currentMailboxId: snap.currentMailboxId,
|
||||
mailboxSnapshots: snap.mailboxSnapshots,
|
||||
emails: mailboxSnap?.emails ?? [],
|
||||
totalEmails: mailboxSnap?.total ?? 0,
|
||||
queryState: mailboxSnap?.queryState,
|
||||
};
|
||||
}
|
||||
|
||||
// JMAP's maxObjectsInGet bounds how many ids we can pull in one Email/get.
|
||||
// Chunk to that ceiling (with a small safety fallback) so large change sets
|
||||
// don't trip 429/413 responses.
|
||||
async function fetchEmailsChunked(ids: string[]): Promise<Email[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const cap = Math.max(1, jmapClient.getMaxObjectsInGet());
|
||||
const chunk = Math.min(cap, 200);
|
||||
if (ids.length <= chunk) return fetchEmails(ids);
|
||||
const out: Email[] = [];
|
||||
for (let i = 0; i < ids.length; i += chunk) {
|
||||
const slice = await fetchEmails(ids.slice(i, i + chunk));
|
||||
out.push(...slice);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Merge a fresh batch of emails into an existing list keyed by id. New entries
|
||||
// replace stale ones (keywords/mailboxIds may have changed); destroyed ids are
|
||||
// dropped. Order is preserved according to the supplied id order — pass the
|
||||
// authoritative id list from Email/query when re-syncing.
|
||||
function applyEmailDiff(
|
||||
current: Email[],
|
||||
orderedIds: string[],
|
||||
fetched: Email[],
|
||||
destroyed: Set<string>,
|
||||
): Email[] {
|
||||
const byId = new Map<string, Email>();
|
||||
for (const e of current) byId.set(e.id, e);
|
||||
for (const e of fetched) byId.set(e.id, e);
|
||||
const out: Email[] = [];
|
||||
for (const id of orderedIds) {
|
||||
if (destroyed.has(id)) continue;
|
||||
const e = byId.get(id);
|
||||
if (e) out.push(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const useEmailStore = create<EmailState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
accountSnapshots: {},
|
||||
activeAccountId: null,
|
||||
|
||||
mailboxes: [],
|
||||
mailboxState: undefined,
|
||||
emailState: undefined,
|
||||
currentMailboxId: null,
|
||||
mailboxSnapshots: {},
|
||||
emails: [],
|
||||
totalEmails: 0,
|
||||
queryState: undefined,
|
||||
|
||||
loading: false,
|
||||
error: null,
|
||||
searchQuery: '',
|
||||
filters: {},
|
||||
pendingUndo: null,
|
||||
|
||||
// Swap which account's data is currently visible. The previous account's
|
||||
// view is tucked into accountSnapshots so a return-trip can restore it
|
||||
// without a network call; the new account's view is pulled from its
|
||||
// snapshot (or empty defaults if we've never seen it). Callers (auth-store)
|
||||
// run the network refresh afterwards.
|
||||
setActiveAccount: (accountId) => {
|
||||
const state = get();
|
||||
if (state.activeAccountId === accountId) return;
|
||||
|
||||
const nextSnapshots = { ...state.accountSnapshots };
|
||||
if (state.activeAccountId) {
|
||||
nextSnapshots[state.activeAccountId] = snapshotFromActive(state);
|
||||
}
|
||||
const incoming = accountId ? nextSnapshots[accountId] ?? null : null;
|
||||
const view = viewFromSnapshot(incoming);
|
||||
|
||||
set({
|
||||
accountSnapshots: nextSnapshots,
|
||||
activeAccountId: accountId,
|
||||
...view,
|
||||
// UI state is reset on switch — search/filters and pending undo belong
|
||||
// to the previous account's intent.
|
||||
searchQuery: '',
|
||||
filters: {},
|
||||
pendingUndo: null,
|
||||
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) => {
|
||||
const state = get();
|
||||
const { [accountId]: _drop, ...rest } = state.accountSnapshots;
|
||||
if (state.activeAccountId === accountId) {
|
||||
set({
|
||||
accountSnapshots: rest,
|
||||
activeAccountId: null,
|
||||
mailboxes: [],
|
||||
mailboxState: undefined,
|
||||
emailState: undefined,
|
||||
currentMailboxId: null,
|
||||
mailboxSnapshots: {},
|
||||
emails: [],
|
||||
totalEmails: 0,
|
||||
queryState: undefined,
|
||||
searchQuery: '',
|
||||
filters: {},
|
||||
pendingUndo: null,
|
||||
});
|
||||
void useOfflineCacheStore.getState().setAccount(null);
|
||||
} else {
|
||||
set({ accountSnapshots: rest });
|
||||
}
|
||||
},
|
||||
|
||||
clearAllAccounts: () => {
|
||||
set({
|
||||
accountSnapshots: {},
|
||||
activeAccountId: null,
|
||||
mailboxes: [],
|
||||
mailboxState: undefined,
|
||||
emailState: undefined,
|
||||
currentMailboxId: null,
|
||||
mailboxSnapshots: {},
|
||||
emails: [],
|
||||
totalEmails: 0,
|
||||
queryState: undefined,
|
||||
searchQuery: '',
|
||||
filters: {},
|
||||
pendingUndo: null,
|
||||
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 {
|
||||
const mailboxes = await fetchMailboxes();
|
||||
set({ mailboxes });
|
||||
// Incremental path: ask for just what changed since last time. Fall
|
||||
// through to a full refetch when the server can't compute the diff or
|
||||
// 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 (
|
||||
changes.created.length === 0 &&
|
||||
changes.updated.length === 0 &&
|
||||
changes.destroyed.length === 0
|
||||
) {
|
||||
set({ mailboxState: changes.newState });
|
||||
return;
|
||||
}
|
||||
const toFetch = [...changes.created, ...changes.updated];
|
||||
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);
|
||||
for (const m of fetched) byId.set(m.id, m);
|
||||
for (const id of destroyed) byId.delete(id);
|
||||
set({
|
||||
mailboxes: Array.from(byId.values()),
|
||||
mailboxState: changes.hasMoreChanges ? prevState : changes.newState,
|
||||
});
|
||||
// hasMoreChanges = there are still pending changes past the
|
||||
// server's response cap. Run the same path again to drain.
|
||||
if (changes.hasMoreChanges) {
|
||||
void get().fetchMailboxes();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// changes === null → cannotCalculateChanges. Fall through to full.
|
||||
}
|
||||
|
||||
const { list, state } = await getMailboxesWithState();
|
||||
if (get().activeAccountId !== activeAccountId) return;
|
||||
set({ mailboxes: list, mailboxState: state });
|
||||
} catch (err) {
|
||||
console.warn('[email-store] fetchMailboxes failed:', err);
|
||||
set({ error: err instanceof Error ? err.message : 'Failed to load mailboxes' });
|
||||
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.
|
||||
if (get().mailboxes.length === 0) {
|
||||
set({ error: err instanceof Error ? err.message : 'Failed to load mailboxes' });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
selectMailbox: async (mailboxId) => {
|
||||
// Reset search/filters when switching mailbox - matches webmail behavior.
|
||||
// Switching mailboxes invalidates any pending undo: the snackbar would be
|
||||
// stale and the restored email would re-appear in a different view.
|
||||
const state = get();
|
||||
// Tuck the previously-visible mailbox into its snapshot so a return-trip
|
||||
// can restore it without a network call. Only do this for the base view —
|
||||
// a filter or search makes the visible list unrepresentative of the
|
||||
// cached "no-filter" snapshot.
|
||||
let mailboxSnapshots = state.mailboxSnapshots;
|
||||
if (
|
||||
state.currentMailboxId &&
|
||||
state.currentMailboxId !== mailboxId &&
|
||||
isBaseView(state.searchQuery, state.filters)
|
||||
) {
|
||||
mailboxSnapshots = {
|
||||
...mailboxSnapshots,
|
||||
[state.currentMailboxId]: {
|
||||
emails: state.emails,
|
||||
total: state.totalEmails,
|
||||
queryState: state.queryState,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const incoming = mailboxSnapshots[mailboxId];
|
||||
// Swap to the new mailbox's cached view immediately. If there's no
|
||||
// snapshot, fall through to the offline cache as a second-best seed;
|
||||
// if that's also empty we render the empty-state, not a spinner over
|
||||
// a blank list — better than the previous flash to "Loading…".
|
||||
let seededEmails: Email[] = incoming?.emails ?? [];
|
||||
let seededTotal = incoming?.total ?? 0;
|
||||
let seededQueryState = incoming?.queryState;
|
||||
|
||||
if (seededEmails.length === 0) {
|
||||
const cacheStore = useOfflineCacheStore.getState();
|
||||
if (!cacheStore.hydrated) await cacheStore.hydrate();
|
||||
if (cacheStore.totalCount() > 0) {
|
||||
try {
|
||||
const limit = useSettingsStore.getState().emailsPerPage;
|
||||
seededEmails = await cacheStore.getEmailsInMailbox(mailboxId, Math.max(limit, 50));
|
||||
seededTotal = seededEmails.length;
|
||||
} catch (err) {
|
||||
console.warn('[email-store] cache seed failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
currentMailboxId: mailboxId,
|
||||
emails: seededEmails,
|
||||
totalEmails: seededTotal,
|
||||
queryState: seededQueryState,
|
||||
mailboxSnapshots,
|
||||
loading: true,
|
||||
error: null,
|
||||
emails: [],
|
||||
totalEmails: 0,
|
||||
searchQuery: '',
|
||||
filters: {},
|
||||
pendingUndo: null,
|
||||
});
|
||||
|
||||
// Seed from the offline cache immediately so changing folders shows
|
||||
// something even before the network responds — and is the fallback
|
||||
// when offline. The network result will replace this once it arrives.
|
||||
const cacheStore = useOfflineCacheStore.getState();
|
||||
if (!cacheStore.hydrated) await cacheStore.hydrate();
|
||||
const limit = useSettingsStore.getState().emailsPerPage;
|
||||
let cached: Email[] = [];
|
||||
if (cacheStore.totalCount() > 0) {
|
||||
try {
|
||||
cached = await cacheStore.getEmailsInMailbox(mailboxId, Math.max(limit, 50));
|
||||
if (cached.length > 0 && get().currentMailboxId === mailboxId) {
|
||||
set({ emails: cached, totalEmails: cached.length });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[email-store] cache seed failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
try {
|
||||
const filter = buildJmapFilter(mailboxId, '', {});
|
||||
const { ids, total } = await queryEmails(mailboxId, { limit, filter });
|
||||
const emails = ids.length > 0 ? await fetchEmails(ids) : [];
|
||||
// Race guard: don't overwrite if the user already moved to a different
|
||||
// mailbox while this request was in flight.
|
||||
if (get().currentMailboxId !== mailboxId) return;
|
||||
set({ emails, totalEmails: total, loading: false });
|
||||
} catch (err) {
|
||||
console.warn('[email-store] selectMailbox failed:', err);
|
||||
if (get().currentMailboxId !== mailboxId) return;
|
||||
// Keep whatever the cache seeded with; only surface the error when the
|
||||
// cache is empty. With cached emails visible the OfflineBanner already
|
||||
// tells the user the data is stale.
|
||||
set({
|
||||
loading: false,
|
||||
error: cached.length > 0 ? null : (err instanceof Error ? err.message : 'Failed to load emails'),
|
||||
});
|
||||
}
|
||||
await get().refreshEmails();
|
||||
},
|
||||
|
||||
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 {
|
||||
@@ -222,42 +542,186 @@ export const useEmailStore = create<EmailState>()(
|
||||
limit,
|
||||
filter,
|
||||
});
|
||||
const newEmails = ids.length > 0 ? await fetchEmails(ids) : [];
|
||||
set({ emails: [...emails, ...newEmails], loading: false });
|
||||
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)) {
|
||||
updates.mailboxSnapshots = {
|
||||
...get().mailboxSnapshots,
|
||||
[currentMailboxId]: {
|
||||
emails: merged,
|
||||
total: get().totalEmails,
|
||||
queryState: get().queryState,
|
||||
},
|
||||
};
|
||||
}
|
||||
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 { currentMailboxId, searchQuery, filters, emails: existing } = get();
|
||||
const state = get();
|
||||
const { currentMailboxId, searchQuery, filters, emails: existing, queryState, emailState, activeAccountId } = state;
|
||||
if (!currentMailboxId) return;
|
||||
if (!jmapClient.isConnected) return;
|
||||
// Keep existing emails visible while refreshing - swap atomically once
|
||||
// the new list is ready so pull-to-refresh doesn't flash an empty list.
|
||||
if (!jmapClientServesActiveAccount(activeAccountId)) return;
|
||||
set({ loading: true, error: null });
|
||||
|
||||
const filter = buildJmapFilter(currentMailboxId, searchQuery, filters);
|
||||
const limit = useSettingsStore.getState().emailsPerPage;
|
||||
const baseView = isBaseView(searchQuery, filters);
|
||||
|
||||
try {
|
||||
const filter = buildJmapFilter(currentMailboxId, searchQuery, filters);
|
||||
const limit = useSettingsStore.getState().emailsPerPage;
|
||||
const { ids, total } = await queryEmails(currentMailboxId, { limit, filter });
|
||||
const emails = ids.length > 0 ? await fetchEmails(ids) : [];
|
||||
set({ emails, totalEmails: total, loading: false });
|
||||
// Incremental sync path: requires the base unfiltered view AND a known
|
||||
// queryState (so Email/queryChanges has something to diff against).
|
||||
// Anything else — search, filter active, first-ever load — falls
|
||||
// through to a full re-query.
|
||||
if (baseView && queryState) {
|
||||
const queryChanges = await getEmailQueryChanges(currentMailboxId, queryState, {
|
||||
filter: undefined,
|
||||
});
|
||||
if (queryChanges) {
|
||||
// What's in the visible window now: drop removed ids, then apply
|
||||
// added (id, index) entries. Newly added ids need bodies fetched.
|
||||
const removed = new Set(queryChanges.removed);
|
||||
const addedIds = queryChanges.added.map((a) => a.id);
|
||||
|
||||
// Email/changes catches updates to messages already in our list
|
||||
// (e.g. another device toggled $seen) that queryChanges wouldn't
|
||||
// report. Skipped when we have no emailState yet — first refresh
|
||||
// after a cold start primes it from the Email/get below.
|
||||
let updatedIds: string[] = [];
|
||||
let destroyedExtra: string[] = [];
|
||||
let nextEmailState: string | undefined = emailState;
|
||||
if (emailState) {
|
||||
const ec = await getEmailChanges(emailState);
|
||||
if (ec) {
|
||||
updatedIds = ec.updated;
|
||||
destroyedExtra = ec.destroyed;
|
||||
nextEmailState = ec.newState;
|
||||
} else {
|
||||
// cannotCalculateChanges → forget the state and rely on the
|
||||
// next full re-sync to repopulate it.
|
||||
nextEmailState = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch only what we don't already have. `addedIds` are new to the
|
||||
// window; `updatedIds` may already be in `existing` but their
|
||||
// keywords/mailboxIds need refreshing.
|
||||
const existingById = new Map(existing.map((e) => [e.id, e]));
|
||||
const idsToFetch = [
|
||||
...addedIds.filter((id) => !existingById.has(id)),
|
||||
...updatedIds.filter((id) => existingById.has(id)),
|
||||
];
|
||||
let fetchState: string | undefined;
|
||||
let fetched: Email[] = [];
|
||||
if (idsToFetch.length > 0) {
|
||||
const res = await getEmailsWithState(idsToFetch);
|
||||
fetched = res.list;
|
||||
fetchState = res.state;
|
||||
}
|
||||
|
||||
// Rebuild the visible window order: start with existing emails,
|
||||
// drop removed/destroyed, then splice added at their indices.
|
||||
const allDestroyed = new Set([...destroyedExtra, ...removed]);
|
||||
const kept = existing.filter((e) => !allDestroyed.has(e.id));
|
||||
// Map updated entries onto kept array
|
||||
const fetchedById = new Map(fetched.map((e) => [e.id, e]));
|
||||
const updatedKept = kept.map((e) => fetchedById.get(e.id) ?? e);
|
||||
|
||||
// Insert added entries at the indices the server gave us. Sort
|
||||
// ascending by index so each splice lands at the right offset.
|
||||
const sortedAdded = [...queryChanges.added].sort((a, b) => a.index - b.index);
|
||||
const out = [...updatedKept];
|
||||
for (const entry of sortedAdded) {
|
||||
const email = fetchedById.get(entry.id);
|
||||
if (!email) continue;
|
||||
const idx = Math.min(entry.index, out.length);
|
||||
out.splice(idx, 0, email);
|
||||
}
|
||||
// Cap the visible list to the user's page size — Email/queryChanges
|
||||
// can push entries past the original window if many were added.
|
||||
const trimmed = out.slice(0, Math.max(limit, out.length));
|
||||
|
||||
const nextQueryState = queryChanges.newQueryState;
|
||||
const nextTotal = queryChanges.total;
|
||||
|
||||
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
|
||||
|
||||
set({
|
||||
emails: trimmed,
|
||||
totalEmails: nextTotal,
|
||||
queryState: nextQueryState,
|
||||
emailState: nextEmailState ?? fetchState ?? emailState,
|
||||
loading: false,
|
||||
mailboxSnapshots: {
|
||||
...get().mailboxSnapshots,
|
||||
[currentMailboxId]: {
|
||||
emails: trimmed,
|
||||
total: nextTotal,
|
||||
queryState: nextQueryState,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
// queryChanges === null → cannotCalculateChanges. Drop our queryState
|
||||
// and fall through to a full re-query, which will repopulate it.
|
||||
}
|
||||
|
||||
// Full re-query path. Used when there's no prior queryState, when the
|
||||
// user has search/filters active (queryState only tracks the base
|
||||
// query), or when the server returned cannotCalculateChanges above.
|
||||
const queryRes = await queryEmails(currentMailboxId, { limit, filter });
|
||||
const fetched = queryRes.ids.length > 0
|
||||
? await getEmailsWithState(queryRes.ids)
|
||||
: { list: [], state: undefined as string | undefined };
|
||||
|
||||
if (get().currentMailboxId !== currentMailboxId) return;
|
||||
|
||||
const updates: Partial<EmailState> = {
|
||||
emails: fetched.list,
|
||||
totalEmails: queryRes.total,
|
||||
loading: false,
|
||||
};
|
||||
if (baseView) {
|
||||
updates.queryState = queryRes.queryState;
|
||||
updates.emailState = fetched.state;
|
||||
updates.mailboxSnapshots = {
|
||||
...get().mailboxSnapshots,
|
||||
[currentMailboxId]: {
|
||||
emails: fetched.list,
|
||||
total: queryRes.total,
|
||||
queryState: queryRes.queryState,
|
||||
},
|
||||
};
|
||||
}
|
||||
set(updates);
|
||||
} catch (err) {
|
||||
console.warn('[email-store] refreshEmails failed:', err);
|
||||
// If the list was empty before the refresh attempt, fall back to the
|
||||
// offline cache so the user still sees something. Otherwise keep what
|
||||
// they had visible and let the OfflineBanner explain.
|
||||
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.
|
||||
if (existing.length === 0) {
|
||||
try {
|
||||
const cacheStore = useOfflineCacheStore.getState();
|
||||
if (!cacheStore.hydrated) await cacheStore.hydrate();
|
||||
if (cacheStore.totalCount() > 0) {
|
||||
const limit = useSettingsStore.getState().emailsPerPage;
|
||||
const cached = await cacheStore.getEmailsInMailbox(
|
||||
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;
|
||||
}
|
||||
@@ -266,12 +730,19 @@ export const useEmailStore = create<EmailState>()(
|
||||
console.warn('[email-store] refresh cache fallback failed:', cacheErr);
|
||||
}
|
||||
}
|
||||
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load emails' });
|
||||
set({
|
||||
loading: false,
|
||||
error: existing.length > 0 ? null : (err instanceof Error ? err.message : 'Failed to load emails'),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
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;
|
||||
@@ -527,9 +998,13 @@ export const useEmailStore = create<EmailState>()(
|
||||
|
||||
reset: () => set({
|
||||
mailboxes: [],
|
||||
mailboxState: undefined,
|
||||
emailState: undefined,
|
||||
currentMailboxId: null,
|
||||
mailboxSnapshots: {},
|
||||
emails: [],
|
||||
totalEmails: 0,
|
||||
queryState: undefined,
|
||||
loading: false,
|
||||
error: null,
|
||||
searchQuery: '',
|
||||
@@ -537,16 +1012,23 @@ export const useEmailStore = create<EmailState>()(
|
||||
}),
|
||||
}),
|
||||
{
|
||||
// Persist the mail list so the UI can render instantly on re-open,
|
||||
// before the JMAP session has finished restoring. auth-store triggers
|
||||
// a background refresh once the session is ready.
|
||||
// Persist the per-account caches and the active view so the UI can
|
||||
// render instantly on re-open / account switch, before the JMAP
|
||||
// session has finished restoring. auth-store triggers a background
|
||||
// refresh once the session is ready.
|
||||
name: 'email-cache',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({
|
||||
accountSnapshots: state.accountSnapshots,
|
||||
activeAccountId: state.activeAccountId,
|
||||
mailboxes: state.mailboxes,
|
||||
mailboxState: state.mailboxState,
|
||||
emailState: state.emailState,
|
||||
currentMailboxId: state.currentMailboxId,
|
||||
mailboxSnapshots: state.mailboxSnapshots,
|
||||
emails: state.emails,
|
||||
totalEmails: state.totalEmails,
|
||||
queryState: state.queryState,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -64,3 +64,33 @@ vi.mock('expo-secure-store', () => ({
|
||||
getItemAsync: vi.fn(async () => null),
|
||||
deleteItemAsync: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
// expo-auth-session and expo-web-browser transitively pull in
|
||||
// expo-modules-core, which evaluates RN-only globals at module load. The unit
|
||||
// tests don't exercise the real OAuth flow, so stubbing the surface is
|
||||
// enough — individual tests that need the real shapes can override.
|
||||
vi.mock('expo-auth-session', () => ({
|
||||
AuthRequest: class {
|
||||
codeVerifier?: string;
|
||||
async promptAsync() {
|
||||
return { type: 'cancel' as const };
|
||||
}
|
||||
},
|
||||
ResponseType: { Code: 'code' },
|
||||
exchangeCodeAsync: vi.fn(async () => ({
|
||||
accessToken: 'test-access',
|
||||
refreshToken: 'test-refresh',
|
||||
expiresIn: 3600,
|
||||
})),
|
||||
refreshAsync: vi.fn(async () => ({
|
||||
accessToken: 'test-access-refreshed',
|
||||
refreshToken: 'test-refresh-rotated',
|
||||
expiresIn: 3600,
|
||||
})),
|
||||
makeRedirectUri: () => 'bulwarkmobile://auth/callback',
|
||||
}));
|
||||
|
||||
vi.mock('expo-web-browser', () => ({
|
||||
warmUpAsync: vi.fn(async () => undefined),
|
||||
maybeCompleteAuthSession: vi.fn(),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user