Compare commits

...
17 Commits
Author SHA1 Message Date
Linus Rath 373a1a60d8 chore: update version number to 0.1.36 2026-05-18 23:32:59 +02:00
Linus Rath f71f219187 feat: implement OAuth login flow and enhance authentication handling 2026-05-18 23:32:50 +02:00
Linus Rath 77b7be03d8 chore: update version number to 0.1.35 2026-05-18 21:52:49 +02:00
Linus Rath 9dd9ce8105 feat: enhance email store and offline cache to support account switching and data isolation 2026-05-18 21:52:35 +02:00
Linus Rath dd64931be8 chore: update version number to 0.1.34 2026-05-18 20:11:20 +02:00
Linus Rath ac234f288e Refactor email store to support incremental sync 2026-05-18 20:05:03 +02:00
Linus Rath 81749dd89c chore: update version number to 0.1.33 2026-05-15 17:26:10 +02:00
Linus Rath beef086d4c chore: update version number to 0.1.32 2026-05-15 17:26:00 +02:00
Linus Rath cfd67398de feat: add capability hooks for calendar, contacts, and files 2026-05-15 17:25:55 +02:00
Linus Rath 2b15d2dc50 chore: update version number to 0.1.31 2026-05-15 02:10:55 +02:00
Linus Rath 9bf2ecdf27 fix: resolve "Not authenticated" race on cold start and account switch 2026-05-15 00:31:57 +02:00
Linus Rath 67db8a2ac3 chore: update version number to 0.1.30 2026-05-14 13:51:00 +02:00
Linus Rath 6846d859fd feat: implement multi-account support for push notifications and enhance teardown logic 2026-05-14 13:50:52 +02:00
Linus Rath 3bdca0133e chore: update version number to 0.1.29 2026-05-14 12:57:18 +02:00
Linus Rath 1858d08dd0 feat: enhance notification handling with accountId support and improve file management functions 2026-05-14 12:57:03 +02:00
Linus Rath 427d163dc0 chore: update version number to 0.1.28 2026-05-08 20:04:32 +02:00
Linus Rath 7e34ba42ef feat: implement in-memory upload with uploadBytes function and integrate into createFolder 2026-05-08 20:04:25 +02:00
30 changed files with 2233 additions and 378 deletions
+53 -8
View File
@@ -15,7 +15,7 @@ import {
getInitialNotificationTap,
getStoredRelayBaseUrl,
setupPushNotifications,
teardownPushNotifications,
teardownPushNotificationsForAccount,
type NotificationTapPayload,
} from './src/lib/push-notifications';
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
@@ -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';
@@ -51,8 +52,21 @@ const Stack = createNativeStackNavigator<RootStackParamList>();
const Tab = createBottomTabNavigator<MainTabsParamList>();
const navigationRef = createNavigationContainerRef<RootStackParamList>();
function navigateToNotificationTap(payload: NotificationTapPayload): void {
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
if (!navigationRef.isReady()) return;
// The notification carries the account it was generated for. If the user
// has since switched to a different account (or had a different one active
// when the notification arrived), opening EmailThread under the active
// account would fetch the email from the wrong server and fail.
const auth = useAuthStore.getState();
if (payload.accountId && payload.accountId !== auth.activeAccountId) {
const account = useAccountStore.getState().getAccountById(payload.accountId);
if (!account) return; // account was logged out — nothing safe to open.
await auth.switchAccount(payload.accountId);
if (useAuthStore.getState().activeAccountId !== payload.accountId) return;
}
navigationRef.navigate('EmailThread', {
emailId: payload.emailId,
threadId: payload.threadId,
@@ -75,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 }}>
@@ -136,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
@@ -143,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
@@ -150,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
@@ -261,11 +300,11 @@ export default function App() {
void (async () => {
const initial = await getInitialNotificationTap();
if (cancelled || !initial) return;
navigateToNotificationTap(initial);
await navigateToNotificationTap(initial);
})();
const unsubscribe = addNotificationTapListener((payload) => {
navigateToNotificationTap(payload);
void navigateToNotificationTap(payload);
});
return () => {
@@ -276,18 +315,24 @@ export default function App() {
// Re-register the device with the configured relay once authenticated,
// and whenever the FCM token rotates. Honours the user's notification
// preference - flipping it off tears down the active subscription so the
// FirebaseMessagingService stops receiving pings for this device.
// preference - flipping it off tears down THIS account's subscription so
// notifications stop arriving for it. Other logged-in accounts keep their
// setups intact.
const emailNotificationsEnabled = useSettingsStore(
(s) => s.emailNotificationsEnabled,
);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
React.useEffect(() => {
if (!isAuthenticated || !client) return;
let cancelled = false;
const doSetup = async () => {
if (!emailNotificationsEnabled) {
await teardownPushNotifications().catch(() => undefined);
if (activeAccountId) {
await teardownPushNotificationsForAccount(activeAccountId).catch(
() => undefined,
);
}
return;
}
const relayBaseUrl = await getStoredRelayBaseUrl();
@@ -315,7 +360,7 @@ export default function App() {
cancelled = true;
unsubscribe();
};
}, [client, isAuthenticated, emailNotificationsEnabled]);
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
React.useEffect(() => {
if (!isAuthenticated || !client) {
+1 -1
View File
@@ -1 +1 @@
0.1.27
0.1.36
@@ -65,6 +65,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
val emailId = options.getString("emailId")
val threadId = options.getString("threadId")
val subject = options.takeIf { it.hasKey("subject") }?.getString("subject")
val accountId = options.takeIf { it.hasKey("accountId") }?.getString("accountId")
// Bitmap fetch + draw off the bridge thread so the caller doesn't
// block waiting for the favicon request.
@@ -73,7 +74,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
?: makeLetterAvatar(initials, bgColorHex)
postNotification(
notificationId, title, body, largeIcon, bgColorHex,
emailId, threadId, subject,
emailId, threadId, subject, accountId,
)
promise.resolve(null)
}
@@ -94,6 +95,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
emailId: String?,
threadId: String?,
subject: String?,
accountId: String?,
) {
val ctx = reactApplicationContext
val intent = Intent(ctx, MainActivity::class.java).apply {
@@ -101,6 +103,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
}
val pending = PendingIntent.getActivity(
ctx,
@@ -23,6 +23,11 @@ object NotificationTapStore {
// our notification builder writes — preventing other apps from
// navigating us to a forged email/thread.
private val ID_PATTERN = Regex("^[A-Za-z0-9_-]{1,128}$")
// accountId is `username@host` (see generateAccountId), so it needs `.`,
// `@`, `+` etc. — cap length and restrict charset to what that helper can
// emit. accountId is optional: notifications created before this field
// existed still navigate (using the active account as a fallback).
private val ACCOUNT_ID_PATTERN = Regex("^[A-Za-z0-9._+@-]{1,256}$")
fun captureFromIntent(intent: Intent?): TapPayload? {
val extras = intent?.extras ?: return null
@@ -34,12 +39,14 @@ object NotificationTapStore {
// cap its length so a hostile launcher can't ship a 1MB string into
// the navigation payload.
val subject = extras.getString(EXTRA_SUBJECT)?.take(512)
val payload = TapPayload(emailId, threadId, subject)
val accountId = extras.getString(EXTRA_ACCOUNT_ID)?.takeIf { ACCOUNT_ID_PATTERN.matches(it) }
val payload = TapPayload(emailId, threadId, subject, accountId)
pending = payload
// Clear so a subsequent activity lifecycle event doesn't replay this.
extras.remove(EXTRA_EMAIL_ID)
extras.remove(EXTRA_THREAD_ID)
extras.remove(EXTRA_SUBJECT)
extras.remove(EXTRA_ACCOUNT_ID)
return payload
}
@@ -47,15 +54,18 @@ object NotificationTapStore {
val emailId: String,
val threadId: String,
val subject: String?,
val accountId: String?,
) {
fun toMap(): WritableMap = Arguments.createMap().apply {
putString("emailId", emailId)
putString("threadId", threadId)
if (subject != null) putString("subject", subject)
if (accountId != null) putString("accountId", accountId)
}
}
const val EXTRA_EMAIL_ID = "bulwark.notification.emailId"
const val EXTRA_THREAD_ID = "bulwark.notification.threadId"
const val EXTRA_SUBJECT = "bulwark.notification.subject"
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
}
+1
View File
@@ -20,6 +20,7 @@ module.exports = {
expo: {
name: 'Bulwark Mobile',
slug: 'bulwark-mobile',
scheme: 'bulwarkmobile',
version: VERSION,
orientation: 'portrait',
icon: './assets/icon.png',
+68 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.27",
"version": "0.1.36",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.27",
"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
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.27",
"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",
+51
View File
@@ -61,6 +61,57 @@ export async function uploadBlob(
throw new Error('Upload succeeded but response did not include a blobId');
}
// Direct in-memory upload. The on-disk variant goes through expo-file-system's
// `new File(uri).bytes()`, which is overkill when we already have the bytes
// (e.g. the empty-blob trick used to create directories on Stalwart).
export async function uploadBytes(
bytes: Uint8Array,
type: string,
): Promise<{ blobId: string; size: number; type: string }> {
const session = jmapClient.currentSession;
if (!session) throw new Error('Not connected');
const uploadUrl = session.uploadUrl.replace(
'{accountId}',
encodeURIComponent(jmapClient.accountId),
);
const response = await secureFetch(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': type || 'application/octet-stream',
Authorization: jmapClient.authHeader,
},
body: bytes.buffer as ArrayBuffer,
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new Error(`Upload failed: ${response.status}${detail ? ` ${detail}` : ''}`);
}
const raw = (await response.json()) as Record<string, unknown>;
const direct = raw as { blobId?: string; type?: string; size?: number };
if (typeof direct.blobId === 'string') {
return {
blobId: direct.blobId,
size: typeof direct.size === 'number' ? direct.size : bytes.byteLength,
type: typeof direct.type === 'string' && direct.type ? direct.type : type,
};
}
const nested = raw[jmapClient.accountId] as
| { blobId?: string; type?: string; size?: number }
| undefined;
if (nested?.blobId) {
return {
blobId: nested.blobId,
size: typeof nested.size === 'number' ? nested.size : bytes.byteLength,
type: typeof nested.type === 'string' && nested.type ? nested.type : type,
};
}
throw new Error('Upload succeeded but response did not include a blobId');
}
export function getDownloadUrl(
blobId: string,
name?: string,
+169 -15
View File
@@ -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([
+100 -14
View File
@@ -1,7 +1,7 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { FileNode } from './types';
import { getDownloadUrl, uploadBlob } from './blob';
import { getDownloadUrl, uploadBlob, uploadBytes } from './blob';
const USING = [CAPABILITIES.CORE, CAPABILITIES.FILES];
@@ -16,17 +16,50 @@ const DIRECTORY_TYPES = new Set([
]);
export function isDirectory(node: Pick<FileNode, 'type'>): boolean {
return DIRECTORY_TYPES.has(node.type) || node.type.includes('directory');
const t = node.type;
if (!t) return false;
return DIRECTORY_TYPES.has(t) || t.includes('directory');
}
// Stalwart's FileNode/get does not support a parentId filter — fetching with
// `ids: null` returns the whole tree, which the caller filters client-side.
// Mirrors the webmail file-store; keeping the API uniform avoids a divergence
// later when we add other JMAP servers.
// Stalwart rejects `/` in FileNode names, so the webmail (and now this app)
// uses U+2215 DIVISION SLASH as the path separator inside the flat `name`
// field. Hierarchy is encoded into the name — there is no `parentId` nesting.
// e.g. a file inside the `Documents` folder is stored with
// `name = "Documents∕report.pdf"`. The display name shown to the user
// strips the prefix.
export const PATH_SEP = '∕';
// Path is a list of folder display names from root. [] is root,
// ['Documents'] is inside Documents, ['Documents','sub'] is inside the
// `sub` folder under Documents.
export function pathToPrefix(path: readonly string[]): string {
if (path.length === 0) return '';
return path.join(PATH_SEP) + PATH_SEP;
}
// True when `node` is a direct child (not a descendant) of `prefix`.
export function isDirectChildOfPrefix(node: Pick<FileNode, 'name'>, prefix: string): boolean {
if (!node.name) return false;
if (prefix === '') return !node.name.includes(PATH_SEP);
if (!node.name.startsWith(prefix)) return false;
const rest = node.name.slice(prefix.length);
return rest.length > 0 && !rest.includes(PATH_SEP);
}
export function nodeDisplayName(node: Pick<FileNode, 'name'>, prefix: string): string {
return prefix && node.name.startsWith(prefix) ? node.name.slice(prefix.length) : node.name;
}
// Stalwart's FileNode/get does not support a parentId filter and some builds
// omit `type` from the default property set. We fetch the whole tree and
// filter client-side; properties are listed explicitly so `isDirectory()`
// always has a value to look at.
const FILE_NODE_PROPERTIES = ['id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated'];
export async function getAllFileNodes(): Promise<FileNode[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['FileNode/get', { accountId, ids: null }, '0']],
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
USING,
);
return (res.methodResponses[0][1].list ?? []) as FileNode[];
@@ -52,10 +85,15 @@ export async function createFileNode(
}
export async function createFolder(
name: string,
parentId: string | null,
displayName: string,
currentPath: readonly string[],
): Promise<FileNode> {
return createFileNode({ name, parentId, type: 'd', blobId: null, size: 0 });
// Stalwart's FileNode/set rejects directory creates without a blobId
// ("Missing blob id"), so we upload an empty blob first and reference it.
// Mirrors the webmail's createFileDirectory.
const blob = await uploadBytes(new Uint8Array(0), 'application/x-directory');
const fullName = pathToPrefix(currentPath) + displayName;
return createFileNode({ name: fullName, type: 'd', blobId: blob.blobId, size: 0 });
}
export async function updateFileNode(
@@ -71,6 +109,33 @@ export async function updateFileNode(
if (notUpdated) throw new Error(notUpdated.description || 'Update failed');
}
// Rename a node and, if it's a folder, cascade the rename to every descendant
// so the encoded path stays consistent. The webmail does this same dance.
export async function renameFileNode(
node: FileNode,
newDisplayName: string,
currentPath: readonly string[],
allNodes: readonly FileNode[],
): Promise<void> {
const prefix = pathToPrefix(currentPath);
const oldServerName = node.name;
const newServerName = prefix + newDisplayName;
if (oldServerName === newServerName) return;
await updateFileNode(node.id, { name: newServerName });
if (isDirectory(node)) {
const oldFolderPrefix = oldServerName + PATH_SEP;
const newFolderPrefix = newServerName + PATH_SEP;
for (const n of allNodes) {
if (n.id === node.id) continue;
if (n.name.startsWith(oldFolderPrefix)) {
await updateFileNode(n.id, { name: newFolderPrefix + n.name.slice(oldFolderPrefix.length) });
}
}
}
}
export async function deleteFileNodes(ids: string[]): Promise<void> {
if (ids.length === 0) return;
const accountId = jmapClient.accountId;
@@ -80,6 +145,27 @@ export async function deleteFileNodes(ids: string[]): Promise<void> {
);
}
// Delete `targets` and every descendant. Stalwart doesn't enforce parent
// references, so we collect descendants by name-prefix and destroy them all
// in one call. Without this, deleting a folder leaves its files orphaned and
// still discoverable at the old encoded path.
export async function deleteWithDescendants(
targets: readonly FileNode[],
allNodes: readonly FileNode[],
): Promise<void> {
const ids = new Set<string>();
for (const node of targets) {
ids.add(node.id);
if (isDirectory(node)) {
const folderPrefix = node.name + PATH_SEP;
for (const n of allNodes) {
if (n.name.startsWith(folderPrefix)) ids.add(n.id);
}
}
}
await deleteFileNodes(Array.from(ids));
}
export function getFileNodeDownloadUrl(node: FileNode): string {
if (!node.blobId) throw new Error('Folders cannot be downloaded');
return getDownloadUrl(node.blobId, node.name, node.type);
@@ -87,14 +173,14 @@ export function getFileNodeDownloadUrl(node: FileNode): string {
export async function uploadFileNode(
uri: string,
name: string,
displayName: string,
mimeType: string,
parentId: string | null,
currentPath: readonly string[],
): Promise<FileNode> {
const blob = await uploadBlob(uri, mimeType);
const fullName = pathToPrefix(currentPath) + displayName;
return createFileNode({
name,
parentId,
name: fullName,
type: blob.type || mimeType || 'application/octet-stream',
blobId: blob.blobId,
size: blob.size,
+152 -12
View File
@@ -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();
+4
View File
@@ -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 {
@@ -17,9 +17,10 @@ import { useSettingsStore, type NotificationSound } from '../../stores/settings-
import {
DEFAULT_RELAY_BASE_URL,
getStoredRelayBaseUrl,
readPushAccountIds,
setStoredRelayBaseUrl,
setupPushNotifications,
teardownPushNotifications,
teardownPushNotificationsForAccount,
} from '../../lib/push-notifications';
const SOUNDS: { value: NotificationSound; label: string }[] = [
@@ -40,6 +41,7 @@ export function NotificationSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const username = useAuthStore((s) => s.username);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
@@ -106,10 +108,20 @@ export function NotificationSettings() {
setConfirmDisable(false);
setPushStatus({ kind: 'busy', message: 'Disabling…' });
try {
await teardownPushNotifications();
await setStoredRelayBaseUrl(null);
setRelayUrl(DEFAULT_RELAY_BASE_URL);
setHasSaved(false);
if (activeAccountId) {
await teardownPushNotificationsForAccount(activeAccountId);
}
// The relay base URL is a device-wide setting shared with any other
// accounts that are still using push. Only clear it (and reset the UI
// to "not configured") when no accounts are using push any more —
// otherwise the remaining accounts would lose their relay URL and
// their next setup attempt would no-op.
const remaining = await readPushAccountIds();
if (remaining.length === 0) {
await setStoredRelayBaseUrl(null);
setRelayUrl(DEFAULT_RELAY_BASE_URL);
setHasSaved(false);
}
setPushStatus({ kind: 'idle' });
} catch (error) {
setPushStatus({
+35
View File
@@ -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));
}
+157
View File
@@ -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();
+132 -48
View File
@@ -8,13 +8,22 @@ import {
getFaviconDomain,
getFaviconUrl,
} from './avatar-utils';
import {
deviceClientIdKey,
lastNotifiedKey,
migrateLegacyPushKeys,
readPushAccountIds,
} from './push-notifications';
const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
// Mirrors STORAGE_KEY in `stores/settings-store.ts`. The headless task can't
// import the Zustand store (would pull in React) so we read AsyncStorage
// directly. Keep this in sync if the store key ever changes.
const SETTINGS_STORAGE_KEY = 'webmail:settings:v1';
// Mirrors the persist name in `stores/account-store.ts`. We use it to find
// the user's active account at the end of the headless task so we can leave
// the jmapClient singleton in a state consistent with what the UI expects
// when the app resumes.
const ACCOUNT_REGISTRY_KEY = 'account-registry';
interface PushPersistedSettings {
emailNotificationsEnabled?: boolean;
@@ -41,76 +50,151 @@ interface ShowNotificationOptions {
emailId: string;
threadId: string;
subject?: string;
accountId: string;
}
interface BulwarkFcmNative {
showNotification(opts: ShowNotificationOptions): Promise<void>;
}
// The relay forwards each JMAP push to FCM tagged with the deviceClientId it
// received the push on (the URL slot from createPushSubscription). The exact
// key the relay uses isn't part of any client-controlled contract, so rather
// than guessing a field name we scan all string values in the data payload
// and look for one that matches a deviceClientId we've registered locally.
// Returns null if nothing matches (e.g. legacy relay payload format) so the
// caller falls back to iterating every known account.
async function identifyAccountFromFcmData(
data: unknown,
accountIds: string[],
): Promise<string | null> {
if (!data || typeof data !== 'object') return null;
const reverseMap = new Map<string, string>();
for (const accountId of accountIds) {
const dcid = await AsyncStorage.getItem(deviceClientIdKey(accountId));
if (dcid) reverseMap.set(dcid, accountId);
}
if (reverseMap.size === 0) return null;
for (const value of Object.values(data as Record<string, unknown>)) {
if (typeof value === 'string') {
const match = reverseMap.get(value);
if (match) return match;
}
}
return null;
}
// Read the persisted active account directly from AsyncStorage (Zustand's
// persist middleware stores the JSON-serialised state under its `name`).
async function readActiveAccountId(): Promise<string | null> {
try {
const raw = await AsyncStorage.getItem(ACCOUNT_REGISTRY_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { state?: { activeAccountId?: string | null } };
return parsed.state?.activeAccountId ?? null;
} catch {
return null;
}
}
// Fired by BulwarkPushTaskService when a data FCM message arrives. Runs in a
// short-lived headless JS runtime - keep it fast, catch all errors, and always
// resolve so the native service can release its wake lock.
export async function pushBackgroundTask(_data: unknown): Promise<void> {
export async function pushBackgroundTask(data: unknown): Promise<void> {
let activeAccountId: string | null = null;
try {
if (!(await emailNotificationsAllowed())) return;
const accountId = await AsyncStorage.getItem(PUSH_ACCOUNT_ID_KEY);
if (!accountId) return;
await migrateLegacyPushKeys();
const loaded = await jmapClient.loadAccount(accountId);
if (!loaded) return;
const accountIds = await readPushAccountIds();
if (accountIds.length === 0) return;
const mailboxes = await getMailboxes();
const inbox = mailboxes.find((m) => m.role === 'inbox');
if (!inbox) return;
activeAccountId = await readActiveAccountId();
const { ids } = await queryEmails(inbox.id, {
filter: { notKeyword: '$seen' },
limit: 1,
});
if (ids.length === 0) return;
const matched = await identifyAccountFromFcmData(data, accountIds);
const accountsToCheck = matched ? [matched] : accountIds;
const emailId = ids[0];
const previouslyNotified = await AsyncStorage.getItem(LAST_NOTIFIED_EMAIL_ID_KEY);
if (previouslyNotified === emailId) return;
const [email] = await getEmails([emailId]);
if (!email) return;
const from = email.from?.[0];
const name = from?.name ?? '';
const address = from?.email ?? '';
const title = name || address || 'New mail';
const body = email.subject || '(no subject)';
const initials = getEmailInitials(name, address);
const bgColorHex = hslToHex(generateEmailAvatarColor(name, address));
const faviconDomain = getFaviconDomain(address);
const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined;
const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined;
if (!native?.showNotification) return;
await native.showNotification({
notificationId: `mail:${emailId}`,
title,
body,
initials,
bgColorHex,
iconUrl,
emailId,
threadId: email.threadId,
subject: email.subject ?? undefined,
});
await AsyncStorage.setItem(LAST_NOTIFIED_EMAIL_ID_KEY, emailId);
for (const accountId of accountsToCheck) {
try {
await processAccountForPush(accountId);
} catch (err) {
console.warn(
'[push] background check failed for account',
accountId,
err instanceof Error ? err.message : err,
);
}
}
} catch (error) {
console.warn(
'[push] background task failed:',
error instanceof Error ? error.message : error,
);
} finally {
// Leave the singleton bound to the user's active account so the UI sees
// a consistent jmapClient state when the app resumes. Without this, the
// last account processed above would remain loaded and the UI's cached
// email-store data would not match what the next JMAP request returns.
if (activeAccountId) {
await jmapClient.loadAccount(activeAccountId).catch(() => undefined);
}
}
}
async function processAccountForPush(accountId: string): Promise<void> {
const loaded = await jmapClient.loadAccount(accountId);
if (!loaded) return;
const mailboxes = await getMailboxes();
const inbox = mailboxes.find((m) => m.role === 'inbox');
if (!inbox) return;
const { ids } = await queryEmails(inbox.id, {
filter: { notKeyword: '$seen' },
limit: 1,
});
if (ids.length === 0) return;
const emailId = ids[0];
const lastKey = lastNotifiedKey(accountId);
const previouslyNotified = await AsyncStorage.getItem(lastKey);
if (previouslyNotified === emailId) return;
const [email] = await getEmails([emailId]);
if (!email) return;
const from = email.from?.[0];
const name = from?.name ?? '';
const address = from?.email ?? '';
const title = name || address || 'New mail';
const body = email.subject || '(no subject)';
const initials = getEmailInitials(name, address);
const bgColorHex = hslToHex(generateEmailAvatarColor(name, address));
const faviconDomain = getFaviconDomain(address);
const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined;
const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined;
if (!native?.showNotification) return;
await native.showNotification({
notificationId: `mail:${emailId}`,
title,
body,
initials,
bgColorHex,
iconUrl,
emailId,
threadId: email.threadId,
subject: email.subject ?? undefined,
accountId,
});
await AsyncStorage.setItem(lastKey, emailId);
}
function hslToHex(hsl: string): string {
const match = hsl.match(/hsl\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\)/);
if (!match) return '#2563eb';
+205 -43
View File
@@ -11,12 +11,88 @@ import { jmapClient } from '../api/jmap-client';
import { generateAccountId } from './account-utils';
// Persist identifiers across launches so we reuse the same JMAP subscription
// after app restarts. deviceClientId also routes to the relay slot.
const DEVICE_CLIENT_ID_KEY = 'push:deviceClientId:v1';
const SUBSCRIPTION_ID_KEY = 'push:subscriptionId:v1';
// after app restarts. Each account gets its own deviceClientId so the hosted
// relay can distinguish per-account pushes via the URL slot it forwards.
const RELAY_BASE_URL_KEY = 'push:relayBaseUrl:v1';
const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
const PUSH_ACCOUNT_IDS_KEY = 'push:accountIds:v1';
const DEVICE_CLIENT_ID_PREFIX = 'push:deviceClientId:v2:';
const SUBSCRIPTION_ID_PREFIX = 'push:subscriptionId:v2:';
export const LAST_NOTIFIED_EMAIL_ID_PREFIX = 'push:lastNotifiedEmailId:v2:';
// Legacy single-account keys (pre-multi-account). Migrated lazily on the next
// setupPushNotifications / pushBackgroundTask call, then deleted.
const LEGACY_DEVICE_CLIENT_ID_KEY = 'push:deviceClientId:v1';
const LEGACY_SUBSCRIPTION_ID_KEY = 'push:subscriptionId:v1';
const LEGACY_PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
const LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
export function deviceClientIdKey(accountId: string): string {
return DEVICE_CLIENT_ID_PREFIX + accountId;
}
function subscriptionIdKey(accountId: string): string {
return SUBSCRIPTION_ID_PREFIX + accountId;
}
export function lastNotifiedKey(accountId: string): string {
return LAST_NOTIFIED_EMAIL_ID_PREFIX + accountId;
}
export async function readPushAccountIds(): Promise<string[]> {
const raw = await AsyncStorage.getItem(PUSH_ACCOUNT_IDS_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((s): s is string => typeof s === 'string');
} catch {
return [];
}
}
async function writePushAccountIds(ids: string[]): Promise<void> {
const deduped = Array.from(new Set(ids));
if (deduped.length === 0) {
await AsyncStorage.removeItem(PUSH_ACCOUNT_IDS_KEY);
} else {
await AsyncStorage.setItem(PUSH_ACCOUNT_IDS_KEY, JSON.stringify(deduped));
}
}
// One-shot migration from the pre-multi-account schema. If the legacy
// PUSH_ACCOUNT_ID_KEY exists, treat that account as the only pre-existing
// setup: reuse the legacy deviceClientId and JMAP subscription id under
// the new per-account keys so the user doesn't lose push on upgrade.
export async function migrateLegacyPushKeys(): Promise<void> {
const legacyAccountId = await AsyncStorage.getItem(LEGACY_PUSH_ACCOUNT_ID_KEY);
if (!legacyAccountId) return;
const legacyDcid = await AsyncStorage.getItem(LEGACY_DEVICE_CLIENT_ID_KEY);
const legacySubId = await AsyncStorage.getItem(LEGACY_SUBSCRIPTION_ID_KEY);
const legacyLastId = await AsyncStorage.getItem(LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY);
if (legacyDcid) {
await AsyncStorage.setItem(deviceClientIdKey(legacyAccountId), legacyDcid);
}
if (legacySubId) {
await AsyncStorage.setItem(subscriptionIdKey(legacyAccountId), legacySubId);
}
if (legacyLastId) {
await AsyncStorage.setItem(lastNotifiedKey(legacyAccountId), legacyLastId);
}
const ids = await readPushAccountIds();
if (!ids.includes(legacyAccountId)) {
await writePushAccountIds([...ids, legacyAccountId]);
}
await AsyncStorage.multiRemove([
LEGACY_PUSH_ACCOUNT_ID_KEY,
LEGACY_DEVICE_CLIENT_ID_KEY,
LEGACY_SUBSCRIPTION_ID_KEY,
LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY,
]);
}
// Hosted relay so users don't need to run their own Firebase project. The
// relay only ever sees FCM tokens + JMAP state-id hashes - no mail content.
@@ -72,11 +148,12 @@ function randomClientId(): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
async function getOrCreateDeviceClientId(): Promise<string> {
const existing = await AsyncStorage.getItem(DEVICE_CLIENT_ID_KEY);
async function getOrCreateDeviceClientId(accountId: string): Promise<string> {
const key = deviceClientIdKey(accountId);
const existing = await AsyncStorage.getItem(key);
if (existing) return existing;
const next = randomClientId();
await AsyncStorage.setItem(DEVICE_CLIENT_ID_KEY, next);
await AsyncStorage.setItem(key, next);
return next;
}
@@ -184,20 +261,24 @@ export async function setupPushNotifications(
const fcmToken = await native.getToken();
if (!fcmToken) throw new Error('FCM token unavailable');
const deviceClientId = await getOrCreateDeviceClientId();
await setStoredRelayBaseUrl(relayBaseUrl);
// Persist the account id so the background push task can reload credentials
// from SecureStore without the main app being running.
// setupPushNotifications operates on the currently-loaded jmapClient. We
// need its username/serverUrl up-front so we can key per-account state.
const username = jmapClient.username;
const serverUrl = jmapClient.serverUrl;
if (username && serverUrl) {
await AsyncStorage.setItem(
PUSH_ACCOUNT_ID_KEY,
generateAccountId(username, serverUrl),
);
if (!username || !serverUrl) {
throw new Error('No account loaded - cannot set up push');
}
const accountId = generateAccountId(username, serverUrl);
await migrateLegacyPushKeys();
const deviceClientId = await getOrCreateDeviceClientId(accountId);
await setStoredRelayBaseUrl(relayBaseUrl);
// Register this account's device-client-id with the relay. Multiple
// accounts on the same device end up as separate registrations sharing
// one fcmToken - the relay forwards each push individually so the headless
// task can identify the source account via the FCM data payload.
await registerWithRelay({
relayBaseUrl,
subscriptionId: deviceClientId,
@@ -208,26 +289,31 @@ export async function setupPushNotifications(
// Reuse the previous JMAP subscription when the server still has it, but
// push the expiry forward so it doesn't time out before the next app start.
const existingSubs = await listPushSubscriptions().catch(() => []);
const storedServerId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
const subKey = subscriptionIdKey(accountId);
const storedServerId = await AsyncStorage.getItem(subKey);
if (storedServerId) {
const match = existingSubs.find((s) => s.id === storedServerId);
if (match) {
const refreshed = await refreshSubscriptionExpires(match);
if (refreshed) {
await addPushAccountId(accountId);
return { subscriptionId: storedServerId, verified: true };
}
// Server rejected the refresh (likely the subscription was already
// deleted server-side) - drop the stale id and recreate below.
await destroyPushSubscription(storedServerId).catch(() => undefined);
}
await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY);
await AsyncStorage.removeItem(subKey);
}
// Reap any leftover Stalwart subscriptions still bound to this device.
// These pile up when a previous enable attempt failed mid-flow (verify
// race, network blip, app killed). Stalwart per-account rate-limits
// PushVerification posts, so leaving stragglers around blocks the new
// one's verification - the symptom is a confusing "code does not match".
// Reap any leftover Stalwart subscriptions still bound to this account's
// deviceClientId. These pile up when a previous enable attempt failed
// mid-flow (verify race, network blip, app killed). Stalwart per-account
// rate-limits PushVerification posts, so leaving stragglers around blocks
// the new one's verification - the symptom is a confusing "code does not
// match". Only reap matches for THIS account's deviceClientId; other
// accounts' subscriptions on this server (rare but possible if the same
// JMAP server backs multiple accounts) must be left alone.
const stragglers = existingSubs.filter(
(s) => s.deviceClientId === deviceClientId && s.id !== storedServerId,
);
@@ -245,11 +331,19 @@ export async function setupPushNotifications(
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
await verifyPushSubscription(serverAssignedId, verificationCode);
await AsyncStorage.setItem(SUBSCRIPTION_ID_KEY, serverAssignedId);
await AsyncStorage.setItem(subKey, serverAssignedId);
await addPushAccountId(accountId);
return { subscriptionId: serverAssignedId, verified: true };
}
async function addPushAccountId(accountId: string): Promise<void> {
const ids = await readPushAccountIds();
if (!ids.includes(accountId)) {
await writePushAccountIds([...ids, accountId]);
}
}
// Push the subscription's expires forward when it's getting close to the
// server's ceiling. Returns false if the server rejects the update, which the
// caller treats as "recreate".
@@ -273,28 +367,92 @@ async function refreshSubscriptionExpires(
}
}
async function deregisterFromRelay(
relayBaseUrl: string,
deviceClientId: string,
): Promise<void> {
await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
{ method: 'DELETE' },
).catch(() => undefined);
}
/**
* Tear down push on logout / disable. Removes the server-side JMAP
* subscription, tells the relay to drop the mapping, and clears the local
* FCM token. Never throws - callers treat teardown as best effort.
* Tear down push for a single account. Destroys that account's JMAP
* subscription (assumes the jmapClient is currently authenticated to that
* account; the active-account logout flow guarantees this) and tells the
* relay to drop its mapping. Other accounts' push setups are untouched.
*
* If no accounts have push left after removal, also deletes the FCM token
* so the device stops receiving FCM messages entirely. Never throws -
* callers treat teardown as best effort.
*/
export async function teardownPushNotificationsForAccount(
accountId: string,
): Promise<void> {
await migrateLegacyPushKeys();
const subKey = subscriptionIdKey(accountId);
const storedSubId = await AsyncStorage.getItem(subKey);
const dcidKey = deviceClientIdKey(accountId);
const storedDcid = await AsyncStorage.getItem(dcidKey);
const relayBaseUrl = await getStoredRelayBaseUrl();
if (storedSubId) {
await destroyPushSubscription(storedSubId).catch(() => undefined);
}
if (relayBaseUrl && storedDcid) {
await deregisterFromRelay(relayBaseUrl, storedDcid);
}
await AsyncStorage.multiRemove([subKey, dcidKey, lastNotifiedKey(accountId)]);
const remaining = (await readPushAccountIds()).filter((id) => id !== accountId);
await writePushAccountIds(remaining);
// If this was the last account with push, kill the FCM token so the device
// truly goes silent. Otherwise the token stays alive so the remaining
// accounts keep receiving pushes.
if (remaining.length === 0) {
const native = getNative();
if (native) {
await native.deleteToken().catch(() => undefined);
}
}
}
/**
* Tear down push for ALL accounts on this device. Used by the logout-all
* flow; best-effort because we typically aren't authenticated to every
* account's JMAP server at the moment we need to call destroy on it. The
* FCM token is always deleted so no push gets through regardless.
*/
export async function teardownPushNotifications(): Promise<void> {
const storedId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
const relayBaseUrl = await getStoredRelayBaseUrl();
const deviceClientId = await AsyncStorage.getItem(DEVICE_CLIENT_ID_KEY);
await migrateLegacyPushKeys();
if (storedId) {
await destroyPushSubscription(storedId).catch(() => undefined);
await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY);
const accountIds = await readPushAccountIds();
const relayBaseUrl = await getStoredRelayBaseUrl();
for (const accountId of accountIds) {
const subKey = subscriptionIdKey(accountId);
const dcidKey = deviceClientIdKey(accountId);
const storedSubId = await AsyncStorage.getItem(subKey);
const storedDcid = await AsyncStorage.getItem(dcidKey);
if (storedSubId) {
// Will only succeed if the jmapClient happens to be authenticated to
// this account right now. We don't switch the client to attempt each
// one; the subscription will expire server-side instead (90-day TTL).
await destroyPushSubscription(storedSubId).catch(() => undefined);
}
if (relayBaseUrl && storedDcid) {
await deregisterFromRelay(relayBaseUrl, storedDcid);
}
await AsyncStorage.multiRemove([subKey, dcidKey, lastNotifiedKey(accountId)]);
}
if (relayBaseUrl && deviceClientId) {
await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
{ method: 'DELETE' },
).catch(() => undefined);
}
await AsyncStorage.removeItem(PUSH_ACCOUNT_ID_KEY);
await AsyncStorage.removeItem(LAST_NOTIFIED_EMAIL_ID_KEY);
await AsyncStorage.removeItem(PUSH_ACCOUNT_IDS_KEY);
const native = getNative();
if (native) {
await native.deleteToken().catch(() => undefined);
@@ -327,6 +485,10 @@ export interface NotificationTapPayload {
emailId: string;
threadId: string;
subject?: string;
// Identifies which logged-in account the notification was generated for.
// Optional for back-compat: older notifications already on the system tray
// won't carry this and will fall back to the active account on tap.
accountId?: string;
}
// Returns - and clears - any pending "notification tap" that launched the app
+100 -101
View File
@@ -18,6 +18,7 @@ import {
FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon,
MoreVertical, Pencil, Share2, Trash2, Upload, X, Download,
} from 'lucide-react-native';
// expo-document-picker is loaded lazily on first upload. Its native module
// is registered at app launch via Expo autolinking; on builds that predate
// the dep being added, requiring it at import time crashes the whole bundle
@@ -37,7 +38,8 @@ function loadDocumentPicker(): DocumentPickerModule | null {
}
import {
createFolder, deleteFileNodes, getAllFileNodes, isDirectory, updateFileNode,
createFolder, deleteWithDescendants, getAllFileNodes, isDirectChildOfPrefix,
isDirectory, nodeDisplayName, pathToPrefix, PATH_SEP, renameFileNode,
uploadFileNode,
} from '../api/files';
import { downloadAttachment, shareAttachment } from '../lib/email-export';
@@ -47,13 +49,12 @@ import { useColors } from '../theme/colors';
import { useSettingsStore, type FilesViewMode } from '../stores/settings-store';
import Dialog from '../components/Dialog';
interface FolderFrame {
id: string | null;
name: string;
// A file row carries the resolved display name alongside the original
// server-side name (the path-encoded `node.name` field).
interface FileRow extends FileNode {
displayName: string;
}
const ROOT_FRAME: FolderFrame = { id: null, name: 'Files' };
function pickFileIcon(name: string) {
const ext = name.split('.').pop()?.toLowerCase() ?? '';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) return FileImage;
@@ -77,10 +78,6 @@ function fileIconColor(name: string, c: ThemePalette): string {
return c.textMuted;
}
function isHidden(node: FileNode): boolean {
return node.name.startsWith('.');
}
function formatFileSize(bytes?: number): string {
if (bytes == null) return '';
if (bytes < 1024) return `${bytes} B`;
@@ -111,7 +108,7 @@ export default function FilesScreen() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [stack, setStack] = useState<FolderFrame[]>([ROOT_FRAME]);
const [path, setPath] = useState<string[]>([]);
const [allNodes, setAllNodes] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
@@ -121,12 +118,12 @@ export default function FilesScreen() {
const [busyId, setBusyId] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<{ ids: string[] } | null>(null);
const [confirmDelete, setConfirmDelete] = useState<{ rows: FileRow[] } | null>(null);
const [newFolderOpen, setNewFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [renameTarget, setRenameTarget] = useState<FileNode | null>(null);
const [renameTarget, setRenameTarget] = useState<FileRow | null>(null);
const [renameValue, setRenameValue] = useState('');
const [actionsTarget, setActionsTarget] = useState<FileNode | null>(null);
const [actionsTarget, setActionsTarget] = useState<FileRow | null>(null);
const showIcons = useSettingsStore((s) => s.filesShowIcons);
const coloredIcons = useSettingsStore((s) => s.filesColoredIcons);
@@ -139,8 +136,9 @@ export default function FilesScreen() {
const [viewOverride, setViewOverride] = useState<FilesViewMode | null>(null);
const viewMode: FilesViewMode = viewOverride ?? defaultViewMode;
const current = stack[stack.length - 1];
const selectionMode = selection.size > 0;
const prefix = useMemo(() => pathToPrefix(path), [path]);
const headerName = path.length === 0 ? 'Files' : path[path.length - 1];
const loadFiles = useCallback(
async (mode: 'initial' | 'refresh' = 'initial') => {
@@ -164,26 +162,31 @@ export default function FilesScreen() {
void loadFiles();
}, [loadFiles]);
// If we navigated into a folder that has since been deleted, walk back up
// until we land on a still-existing one (or root).
// If we navigated into a folder that no longer exists (it was deleted, or
// we never had it), walk back up until we land on one that does. The check
// looks for a directory node whose serverName matches the encoded path.
useEffect(() => {
if (allNodes.length === 0) return;
setStack((prev) => {
const ids = new Set(allNodes.map((n) => n.id));
let cut = prev.length;
for (let i = prev.length - 1; i > 0; i--) {
const id = prev[i].id;
if (id != null && !ids.has(id)) cut = i;
setPath((prev) => {
let next = prev;
while (next.length > 0) {
const parentPrefix = pathToPrefix(next.slice(0, -1));
const expectedName = parentPrefix + next[next.length - 1];
const exists = allNodes.some((n) => n.name === expectedName && isDirectory(n));
if (exists) break;
next = next.slice(0, -1);
}
return cut === prev.length ? prev : prev.slice(0, cut);
return next.length === prev.length ? prev : next;
});
}, [allNodes]);
const visibleFiles = useMemo(() => {
let out = allNodes.filter((n) => (n.parentId ?? null) === current.id);
if (!showHiddenFiles) out = out.filter((f) => !isHidden(f));
const visibleFiles = useMemo<FileRow[]>(() => {
const rows = allNodes
.filter((n) => isDirectChildOfPrefix(n, prefix))
.map<FileRow>((n) => ({ ...n, displayName: nodeDisplayName(n, prefix) }))
.filter((r) => showHiddenFiles || !r.displayName.startsWith('.'));
const dir = sortDir === 'desc' ? -1 : 1;
return [...out].sort((a, b) => {
return rows.sort((a, b) => {
const aDir = isDirectory(a) ? 0 : 1;
const bDir = isDirectory(b) ? 0 : 1;
if (aDir !== bDir) return aDir - bDir;
@@ -197,10 +200,10 @@ export default function FilesScreen() {
}
case 'name':
default:
return a.name.localeCompare(b.name) * dir;
return a.displayName.localeCompare(b.displayName) * dir;
}
});
}, [allNodes, current.id, showHiddenFiles, sortKey, sortDir]);
}, [allNodes, prefix, showHiddenFiles, sortKey, sortDir]);
const clearSelection = useCallback(() => setSelection(new Set()), []);
@@ -213,11 +216,11 @@ export default function FilesScreen() {
});
}, []);
const previewFile = useCallback(async (node: FileNode) => {
if (!node.blobId) return;
setBusyId(node.id);
const previewFile = useCallback(async (row: FileRow) => {
if (!row.blobId) return;
setBusyId(row.id);
try {
await shareAttachment(node.blobId, node.name, node.type);
await shareAttachment(row.blobId, row.displayName, row.type);
} catch (e) {
Alert.alert('Preview failed', e instanceof Error ? e.message : String(e));
} finally {
@@ -225,11 +228,11 @@ export default function FilesScreen() {
}
}, []);
const downloadFile = useCallback(async (node: FileNode) => {
if (!node.blobId) return;
setBusyId(node.id);
const downloadFile = useCallback(async (row: FileRow) => {
if (!row.blobId) return;
setBusyId(row.id);
try {
await downloadAttachment(node.blobId, node.name, node.type);
await downloadAttachment(row.blobId, row.displayName, row.type);
} catch (e) {
Alert.alert('Download failed', e instanceof Error ? e.message : String(e));
} finally {
@@ -238,26 +241,26 @@ export default function FilesScreen() {
}, []);
const handleRowPress = useCallback(
(node: FileNode) => {
(row: FileRow) => {
if (selectionMode) {
toggleSelect(node.id);
toggleSelect(row.id);
return;
}
if (isDirectory(node)) {
setStack((s) => [...s, { id: node.id, name: node.name }]);
if (isDirectory(row)) {
setPath((p) => [...p, row.displayName]);
return;
}
void previewFile(node);
void previewFile(row);
},
[selectionMode, toggleSelect, previewFile],
);
const handleRowLongPress = useCallback((node: FileNode) => {
toggleSelect(node.id);
const handleRowLongPress = useCallback((row: FileRow) => {
toggleSelect(row.id);
}, [toggleSelect]);
const goBack = useCallback(() => {
setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));
setPath((p) => (p.length > 0 ? p.slice(0, -1) : p));
clearSelection();
}, [clearSelection]);
@@ -274,33 +277,41 @@ export default function FilesScreen() {
const submitNewFolder = useCallback(async () => {
const name = newFolderName.trim();
if (!name) return;
if (name.includes(PATH_SEP) || name.includes('/')) {
Alert.alert('Invalid name', 'Folder names cannot contain "/".');
return;
}
setNewFolderOpen(false);
setNewFolderName('');
try {
await createFolder(name, current.id);
await createFolder(name, path);
await loadFiles('refresh');
} catch (e) {
Alert.alert('Create folder failed', e instanceof Error ? e.message : String(e));
}
}, [newFolderName, current.id, loadFiles]);
}, [newFolderName, path, loadFiles]);
const submitRename = useCallback(async () => {
if (!renameTarget) return;
const name = renameValue.trim();
if (!name || name === renameTarget.name) {
if (!name || name === renameTarget.displayName) {
setRenameTarget(null);
return;
}
if (name.includes(PATH_SEP) || name.includes('/')) {
Alert.alert('Invalid name', 'Names cannot contain "/".');
return;
}
const target = renameTarget;
setRenameTarget(null);
setRenameValue('');
try {
await updateFileNode(target.id, { name });
await renameFileNode(target, name, path, allNodes);
await loadFiles('refresh');
} catch (e) {
Alert.alert('Rename failed', e instanceof Error ? e.message : String(e));
}
}, [renameTarget, renameValue, loadFiles]);
}, [renameTarget, renameValue, path, allNodes, loadFiles]);
const startUpload = useCallback(async () => {
if (uploading) return;
@@ -330,7 +341,7 @@ export default function FilesScreen() {
asset.uri,
asset.name,
asset.mimeType || 'application/octet-stream',
current.id,
path,
);
await loadFiles('refresh');
} catch (e) {
@@ -338,33 +349,20 @@ export default function FilesScreen() {
} finally {
setUploading(false);
}
}, [uploading, current.id, loadFiles]);
}, [uploading, path, loadFiles]);
const requestDelete = useCallback((ids: string[]) => {
if (ids.length === 0) return;
setConfirmDelete({ ids });
const requestDelete = useCallback((rows: FileRow[]) => {
if (rows.length === 0) return;
setConfirmDelete({ rows });
}, []);
const performDelete = useCallback(async () => {
if (!confirmDelete) return;
// Cascade: if the user deletes a folder, also delete every descendant
// we know about. Stalwart deletes folders with content silently otherwise.
const targetIds = new Set(confirmDelete.ids);
let frontier = [...confirmDelete.ids];
while (frontier.length > 0) {
const next: string[] = [];
for (const node of allNodes) {
if (node.parentId && frontier.includes(node.parentId) && !targetIds.has(node.id)) {
targetIds.add(node.id);
next.push(node.id);
}
}
frontier = next;
}
const targets = confirmDelete.rows;
setConfirmDelete(null);
clearSelection();
try {
await deleteFileNodes(Array.from(targetIds));
await deleteWithDescendants(targets, allNodes);
await loadFiles('refresh');
} catch (e) {
Alert.alert('Delete failed', e instanceof Error ? e.message : String(e));
@@ -372,9 +370,10 @@ export default function FilesScreen() {
}, [confirmDelete, allNodes, clearSelection, loadFiles]);
const renderHeader = () => {
const canBack = stack.length > 1;
const canBack = path.length > 0;
const ToggleIcon = viewMode === 'list' ? LayoutGrid : ListIcon;
if (selectionMode) {
const selectedRows = visibleFiles.filter((r) => selection.has(r.id));
return (
<View style={styles.header}>
<View style={styles.headerRow}>
@@ -384,7 +383,7 @@ export default function FilesScreen() {
<Text style={styles.title}>{selection.size} selected</Text>
<View style={styles.headerActions}>
<Pressable
onPress={() => requestDelete(Array.from(selection))}
onPress={() => requestDelete(selectedRows)}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel="Delete selected"
@@ -406,7 +405,7 @@ export default function FilesScreen() {
</Pressable>
) : null}
<Text style={styles.title} numberOfLines={1}>
{current.name}
{headerName}
</Text>
</View>
<View style={styles.headerActions}>
@@ -443,19 +442,19 @@ export default function FilesScreen() {
</View>
{canBack ? (
<Text style={styles.breadcrumb} numberOfLines={1}>
{stack.map((f) => f.name).join(' / ')}
{['Files', ...path].join(' / ')}
</Text>
) : null}
</View>
);
};
const renderRow = (item: FileNode) => {
const renderRow = (item: FileRow) => {
const isDir = isDirectory(item);
const Icon = isDir ? Folder : pickFileIcon(item.name);
const Icon = isDir ? Folder : pickFileIcon(item.displayName);
const tint = isDir
? (coloredIcons ? c.calendar.blue : c.textMuted)
: (coloredIcons ? fileIconColor(item.name, c) : c.textMuted);
: (coloredIcons ? fileIconColor(item.displayName, c) : c.textMuted);
const selected = selection.has(item.id);
const busy = busyId === item.id;
return (
@@ -472,7 +471,7 @@ export default function FilesScreen() {
{showIcons && <Icon size={20} color={tint} />}
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>
{item.name}
{item.displayName}
</Text>
<View style={styles.fileMetaRow}>
{item.size != null && !isDir ? (
@@ -501,12 +500,12 @@ export default function FilesScreen() {
);
};
const renderGridCell = (item: FileNode) => {
const renderGridCell = (item: FileRow) => {
const isDir = isDirectory(item);
const Icon = isDir ? Folder : pickFileIcon(item.name);
const Icon = isDir ? Folder : pickFileIcon(item.displayName);
const tint = isDir
? (coloredIcons ? c.calendar.blue : c.textMuted)
: (coloredIcons ? fileIconColor(item.name, c) : c.textMuted);
: (coloredIcons ? fileIconColor(item.displayName, c) : c.textMuted);
const selected = selection.has(item.id);
return (
<Pressable
@@ -525,7 +524,7 @@ export default function FilesScreen() {
<View style={{ width: 36, height: 36 }} />
)}
<Text style={styles.gridName} numberOfLines={2}>
{item.name}
{item.displayName}
</Text>
{item.size != null && !isDir ? (
<Text style={styles.gridMeta} numberOfLines={1}>
@@ -560,7 +559,7 @@ export default function FilesScreen() {
<View style={styles.center}>
<HardDrive size={48} color={c.textMuted} />
<Text style={styles.emptyText}>
{stack.length > 1 ? 'This folder is empty' : 'No files yet'}
{path.length > 0 ? 'This folder is empty' : 'No files yet'}
</Text>
</View>
);
@@ -629,30 +628,30 @@ export default function FilesScreen() {
<ActionsSheet
target={actionsTarget}
onClose={() => setActionsTarget(null)}
onPreview={(n) => {
onPreview={(r) => {
setActionsTarget(null);
void previewFile(n);
void previewFile(r);
}}
onDownload={(n) => {
onDownload={(r) => {
setActionsTarget(null);
void downloadFile(n);
void downloadFile(r);
}}
onRename={(n) => {
onRename={(r) => {
setActionsTarget(null);
setRenameTarget(n);
setRenameValue(n.name);
setRenameTarget(r);
setRenameValue(r.displayName);
}}
onDelete={(n) => {
onDelete={(r) => {
setActionsTarget(null);
requestDelete([n.id]);
requestDelete([r]);
}}
/>
<Dialog
visible={confirmDelete != null}
title={
confirmDelete && confirmDelete.ids.length > 1
? `Delete ${confirmDelete.ids.length} items?`
confirmDelete && confirmDelete.rows.length > 1
? `Delete ${confirmDelete.rows.length} items?`
: 'Delete this item?'
}
message="Folders are deleted along with everything inside them. This can't be undone."
@@ -722,12 +721,12 @@ function PromptModal(props: PromptModalProps) {
}
interface ActionsSheetProps {
target: FileNode | null;
target: FileRow | null;
onClose: () => void;
onPreview: (n: FileNode) => void;
onDownload: (n: FileNode) => void;
onRename: (n: FileNode) => void;
onDelete: (n: FileNode) => void;
onPreview: (r: FileRow) => void;
onDownload: (r: FileRow) => void;
onRename: (r: FileRow) => void;
onDelete: (r: FileRow) => void;
}
function ActionsSheet({ target, onClose, onPreview, onDownload, onRename, onDelete }: ActionsSheetProps) {
@@ -742,7 +741,7 @@ function ActionsSheet({ target, onClose, onPreview, onDownload, onRename, onDele
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<Text style={styles.sheetTitle} numberOfLines={1}>
{target.name}
{target.displayName}
</Text>
{!isDir ? (
<Pressable style={styles.action} onPress={() => onPreview(target)}>
+29 -2
View File
@@ -20,6 +20,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const login = useAuthStore((state) => state.login);
const 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>
+21 -3
View File
@@ -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} />
+1
View File
@@ -25,6 +25,7 @@ vi.mock('../../api/jmap-client', () => ({
vi.mock('../../lib/push-notifications', () => ({
teardownPushNotifications: vi.fn(async () => undefined),
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
}));
import { jmapClient } from '../../api/jmap-client';
@@ -20,6 +20,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
isConnected: true,
},
}));
@@ -20,6 +20,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
isConnected: true,
},
}));
+64 -7
View File
@@ -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(),
@@ -17,15 +25,57 @@ vi.mock('../../api/email', () => ({
// trip on react-native's Flow-typed entrypoint under vitest. The store only
// reads archiveMode in archiveEmail, so a minimal stub is enough.
vi.mock('../settings-store', () => ({
useSettingsStore: { getState: () => ({ archiveMode: 'single' }) },
useSettingsStore: { getState: () => ({ archiveMode: 'single', emailsPerPage: 25 }) },
}));
// 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>;
@@ -35,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', () => {
@@ -44,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();
@@ -52,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();
@@ -62,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');
@@ -79,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();
});
});
+142 -18
View File
@@ -6,7 +6,11 @@ import { useEmailStore } from './email-store';
import { useContactsStore } from './contacts-store';
import { useCalendarStore } from './calendar-store';
import { generateAccountId } from '../lib/account-utils';
import { teardownPushNotifications } from '../lib/push-notifications';
import { runOAuthFlow, OAuthCancelledError } from '../lib/oauth';
import {
teardownPushNotifications,
teardownPushNotificationsForAccount,
} from '../lib/push-notifications';
// Persist middleware hydrates asynchronously on cold start. Without this
// guard, restoreSession() can read the account-store before AsyncStorage has
@@ -35,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>;
@@ -43,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();
}
@@ -105,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);
@@ -126,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) {
@@ -139,13 +169,63 @@ 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;
// Best-effort: revoke the JMAP PushSubscription and drop the relay
// mapping before we lose credentials. Do not abort logout on failure.
await teardownPushNotifications().catch(() => undefined);
// Best-effort: revoke this account's JMAP PushSubscription and drop its
// relay mapping before we lose credentials. Other logged-in accounts'
// push setups remain untouched. Do not abort logout on failure.
if (currentId) {
await teardownPushNotificationsForAccount(currentId).catch(() => undefined);
} else {
await teardownPushNotifications().catch(() => undefined);
}
// Clear credentials for this account first
if (currentId) {
@@ -156,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;
@@ -190,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);
@@ -217,14 +297,50 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({ isLoading: true, error: null });
jmapClient.reset();
resetFeatureStores();
// 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);
const ok = await jmapClient.loadAccount(accountId);
if (!ok) {
// Credentials missing - evict stale entry and surface error
accountStore.removeAccount(accountId);
set({ isLoading: false, error: 'Session expired for this account' });
// 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;
}
} catch (err) {
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',
});
return;
}
@@ -287,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;
}
@@ -325,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;
}
+7
View File
@@ -89,6 +89,11 @@ export const useCalendarStore = create<CalendarState>()(
},
fetchCalendars: async () => {
// CalendarScreen fires this on mount; on cold start that happens before
// restoreSession has connected jmapClient. Bail rather than surfacing
// a "Not authenticated" error - the refetch driven by the auth-store
// will run this again once the session is live.
if (!jmapClient.isConnected) return;
try {
const calendars = (await fetchCalendars()) ?? [];
set({ calendars });
@@ -98,6 +103,7 @@ export const useCalendarStore = create<CalendarState>()(
},
fetchEvents: async (calendarIds, after, before) => {
if (!jmapClient.isConnected) return;
set({ loading: true, error: null });
try {
const ids = (await queryEvents(calendarIds, after, before)) ?? [];
@@ -142,6 +148,7 @@ export const useCalendarStore = create<CalendarState>()(
},
handleStateChange: async (change) => {
if (!jmapClient.isConnected) return;
const accountId = jmapClient.accountId;
const accountChanges = change.changed?.[accountId];
if (!accountChanges) return;
+6
View File
@@ -87,6 +87,10 @@ export const useContactsStore = create<ContactsState>()(
},
fetchAddressBooks: async () => {
// No-op until a JMAP session exists. ContactsScreen fires this from a
// mount-time useEffect, which on cold start runs before restoreSession
// has set up the client.
if (!jmapClient.isConnected) return;
try {
const addressBooks = (await fetchAddressBooks()) ?? [];
set({ addressBooks });
@@ -96,6 +100,7 @@ export const useContactsStore = create<ContactsState>()(
},
fetchContacts: async (filter) => {
if (!jmapClient.isConnected) return;
set({ loading: true, error: null });
try {
const ids = (await queryContacts(filter)) ?? [];
@@ -111,6 +116,7 @@ export const useContactsStore = create<ContactsState>()(
},
handleStateChange: async (change) => {
if (!jmapClient.isConnected) return;
const accountId = jmapClient.accountId;
const accountChanges = change.changed?.[accountId];
if (!accountChanges) return;
+564 -64
View File
@@ -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,86 +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, 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 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 (!jmapClientServesActiveAccount(activeAccountId)) return;
set({ loading: true });
try {
@@ -205,41 +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;
// 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;
}
@@ -248,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;
@@ -509,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: '',
@@ -519,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,
}),
},
),
+103 -32
View File
@@ -3,16 +3,25 @@
// AsyncStorage; attachment blobs go to expo-file-system under
// `Paths.document/offline-attachments/`. The store also exposes the live
// progress state the OfflineCacheBanner reads from.
//
// Storage is keyed by the email-store's `activeAccountId` so logged-in
// accounts don't leak cached bodies into each other. The store loads one
// account's bucket at a time (the active one); switching accounts via
// setAccount() persists the current bucket then hydrates the new one.
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Email } from '../api/types';
const INDEX_KEY = 'webmail:offline-cache:index:v1';
const ENTRY_KEY_PREFIX = 'webmail:offline-cache:entry:v1:';
const INDEX_KEY_PREFIX = 'webmail:offline-cache:index:v2:';
const ENTRY_KEY_PREFIX = 'webmail:offline-cache:entry:v2:';
function entryKey(id: string): string {
return `${ENTRY_KEY_PREFIX}${id}`;
function indexKey(accountId: string): string {
return `${INDEX_KEY_PREFIX}${accountId}`;
}
function entryKey(accountId: string, emailId: string): string {
return `${ENTRY_KEY_PREFIX}${accountId}:${emailId}`;
}
export interface OfflineCacheIndexEntry {
@@ -23,8 +32,6 @@ export interface OfflineCacheIndexEntry {
}
interface CacheIndex {
// Per-account is keyed at the storage layer (one app session = one account
// active at a time). Cross-account separation is left to a future change.
entries: Record<string, OfflineCacheIndexEntry>;
}
@@ -42,8 +49,11 @@ export interface SyncState {
}
const IDLE: SyncState = { phase: 'idle', total: 0, completed: 0, fetched: 0, bytes: 0 };
const EMPTY_INDEX: CacheIndex = { entries: {} };
interface OfflineCacheState {
// Currently-loaded bucket. null = no account active (no reads/writes).
activeAccountId: string | null;
index: CacheIndex;
hydrated: boolean;
sync: SyncState;
@@ -52,6 +62,9 @@ interface OfflineCacheState {
// AbortControllers need to survive across the function boundary.
abortRequested: boolean;
// Point the store at the given account, hydrating its bucket from
// AsyncStorage. Pass null to detach (no reads/writes will hit storage).
setAccount: (accountId: string | null) => Promise<void>;
hydrate: () => Promise<void>;
has: (id: string) => boolean;
get: (id: string) => Promise<Email | null>;
@@ -74,41 +87,83 @@ interface OfflineCacheState {
consumeAbort: () => boolean;
}
function persistIndex(index: CacheIndex): void {
void AsyncStorage.setItem(INDEX_KEY, JSON.stringify(index)).catch((err) => {
function persistIndex(accountId: string, index: CacheIndex): void {
void AsyncStorage.setItem(indexKey(accountId), JSON.stringify(index)).catch((err) => {
console.warn('[offline-cache] persist index failed', err);
});
}
async function loadIndex(accountId: string): Promise<CacheIndex> {
try {
const raw = await AsyncStorage.getItem(indexKey(accountId));
if (raw) {
const parsed = JSON.parse(raw) as CacheIndex;
if (parsed && typeof parsed === 'object' && parsed.entries) {
return parsed;
}
}
} catch (err) {
console.warn('[offline-cache] hydrate failed', err);
}
return { entries: {} };
}
export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
activeAccountId: null,
index: { entries: {} },
hydrated: false,
sync: { ...IDLE },
abortRequested: false,
hydrate: async () => {
if (get().hydrated) return;
try {
const raw = await AsyncStorage.getItem(INDEX_KEY);
if (raw) {
const parsed = JSON.parse(raw) as CacheIndex;
if (parsed && typeof parsed === 'object' && parsed.entries) {
set({ index: parsed, hydrated: true });
return;
}
}
} catch (err) {
console.warn('[offline-cache] hydrate failed', err);
setAccount: async (accountId) => {
const state = get();
if (state.activeAccountId === accountId) {
// Same account — make sure we've hydrated.
if (!state.hydrated) await get().hydrate();
return;
}
set({ hydrated: true });
// Drop any in-flight sync state from the previous account; the abort flag
// makes runOfflineSync stop on its next chunk boundary.
set({
activeAccountId: accountId,
index: { ...EMPTY_INDEX },
hydrated: false,
sync: { ...IDLE },
abortRequested: true,
});
if (accountId) {
const index = await loadIndex(accountId);
// Re-check in case another setAccount call raced past us.
if (get().activeAccountId !== accountId) return;
set({ index, hydrated: true, abortRequested: false });
} else {
set({ hydrated: true, abortRequested: false });
}
},
hydrate: async () => {
const state = get();
if (state.hydrated) return;
const accountId = state.activeAccountId;
if (!accountId) {
// No active account yet — mark hydrated so callers that block on this
// (selectMailbox cache-seed path) don't spin. Reads will return empty.
set({ hydrated: true });
return;
}
const index = await loadIndex(accountId);
if (get().activeAccountId !== accountId) return;
set({ index, hydrated: true });
},
has: (id) => Boolean(get().index.entries[id]),
get: async (id) => {
if (!get().index.entries[id]) return null;
const state = get();
if (!state.activeAccountId) return null;
if (!state.index.entries[id]) return null;
try {
const raw = await AsyncStorage.getItem(entryKey(id));
const raw = await AsyncStorage.getItem(entryKey(state.activeAccountId, id));
if (!raw) return null;
return JSON.parse(raw) as Email;
} catch (err) {
@@ -118,12 +173,19 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
},
put: async (email, approxBytes) => {
const state = get();
const accountId = state.activeAccountId;
if (!accountId) return;
try {
await AsyncStorage.setItem(entryKey(email.id), JSON.stringify(email));
await AsyncStorage.setItem(entryKey(accountId, email.id), JSON.stringify(email));
} catch (err) {
console.warn('[offline-cache] put failed', email.id, err);
return;
}
// Re-read the active account in case it changed while we were awaiting
// AsyncStorage — don't stamp the new account's index with the old
// account's entry.
if (get().activeAccountId !== accountId) return;
const nextEntries = {
...get().index.entries,
[email.id]: {
@@ -135,35 +197,44 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
};
const index = { entries: nextEntries };
set({ index });
persistIndex(index);
persistIndex(accountId, index);
},
remove: async (ids) => {
if (ids.length === 0) return;
const accountId = get().activeAccountId;
if (!accountId) return;
await Promise.all(
ids.map((id) => AsyncStorage.removeItem(entryKey(id)).catch(() => undefined)),
ids.map((id) => AsyncStorage.removeItem(entryKey(accountId, id)).catch(() => undefined)),
);
if (get().activeAccountId !== accountId) return;
const next = { ...get().index.entries };
for (const id of ids) delete next[id];
const index = { entries: next };
set({ index });
persistIndex(index);
persistIndex(accountId, index);
},
clearAll: async () => {
const accountId = get().activeAccountId;
if (!accountId) return;
const ids = Object.keys(get().index.entries);
await Promise.all(
ids.map((id) => AsyncStorage.removeItem(entryKey(id)).catch(() => undefined)),
ids.map((id) => AsyncStorage.removeItem(entryKey(accountId, id)).catch(() => undefined)),
);
if (get().activeAccountId !== accountId) return;
const index = { entries: {} };
set({ index, sync: { ...IDLE } });
persistIndex(index);
persistIndex(accountId, index);
},
getEmailsInMailbox: async (mailboxId, limit = 200) => {
const state = get();
const accountId = state.activeAccountId;
if (!accountId) return [];
// Sort the index by receivedAt descending first so we don't have to read
// entries we won't return — the index already carries receivedAt.
const sorted = Object.values(get().index.entries).sort((a, b) => {
const sorted = Object.values(state.index.entries).sort((a, b) => {
const at = a.receivedAt ? new Date(a.receivedAt).getTime() : 0;
const bt = b.receivedAt ? new Date(b.receivedAt).getTime() : 0;
return bt - at;
@@ -172,7 +243,7 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
for (const entry of sorted) {
if (matches.length >= limit) break;
try {
const raw = await AsyncStorage.getItem(entryKey(entry.id));
const raw = await AsyncStorage.getItem(entryKey(accountId, entry.id));
if (!raw) continue;
const email = JSON.parse(raw) as Email;
if (email.mailboxIds?.[mailboxId]) {
+30
View File
@@ -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(),
}));