Compare commits

..
4 Commits
15 changed files with 455 additions and 119 deletions
+11 -5
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';
@@ -289,18 +289,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();
@@ -328,7 +334,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.29
0.1.31
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.29",
"version": "0.1.31",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.29",
"version": "0.1.31",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.29",
"version": "0.1.31",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
@@ -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({
+131 -49
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;
@@ -48,71 +57,144 @@ 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,
accountId,
});
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';
+201 -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);
+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,
},
}));
+12 -1
View File
@@ -17,7 +17,18 @@ 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 }) },
}));
// email-store now early-returns from fetch actions when jmapClient.isConnected
// is false (cold-start guard). The tests exercise those actions, so present
// a connected stub.
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
isConnected: true,
accountId: 'acc-1',
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
},
}));
import * as emailApi from '../../api/email';
+45 -12
View File
@@ -6,7 +6,10 @@ 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 {
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
@@ -143,9 +146,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
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) {
@@ -217,17 +225,42 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({ isLoading: true, error: null });
jmapClient.reset();
resetFeatureStores();
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' });
// Load the new account's session BEFORE clearing jmapClient or feature
// stores. The previous order (reset → resetFeatureStores → loadAccount)
// left jmapClient with no accountId during the loadAccount await, and
// any fetch fired by a screen effect reacting to the emptied stores
// (e.g. EmailListScreen retrying fetchMailboxes when mailboxes goes to
// []) threw "Not authenticated - call connect() first". loadAccount
// overwrites credentials/session/_accountId itself, so the pre-reset
// is unnecessary.
try {
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' });
return;
}
} catch (err) {
if (err instanceof AuthenticationError) {
await jmapClient.clearAccountCredentials(accountId).catch(() => undefined);
accountStore.removeAccount(accountId);
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.
set({
isLoading: false,
error: err instanceof Error ? err.message : 'Failed to switch account',
});
return;
}
// Session is in place under the new account. Safe now to wipe the
// previous account's cached feature data; the refetch below repopulates.
resetFeatureStores();
accountStore.setActiveAccount(accountId);
accountStore.updateAccount(accountId, {
isConnected: true,
+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;
+18
View File
@@ -129,6 +129,13 @@ export const useEmailStore = create<EmailState>()(
pendingUndo: null,
fetchMailboxes: async () => {
// Skip silently when there's no live session. Screens fire this from
// mount-time useEffects, and on cold start App.tsx renders MainTabs
// before restoreSession() finishes; without this guard the underlying
// API call would throw "Not authenticated - call connect() first" and
// surface as a user-visible error before the real fetch (kicked off by
// refetchFeatureStores() once the session is live) replaces it.
if (!jmapClient.isConnected) return;
try {
const mailboxes = await fetchMailboxes();
set({ mailboxes });
@@ -171,6 +178,15 @@ export const useEmailStore = create<EmailState>()(
}
}
// Stop here if there's no live session yet — the cached seed already
// gave the user something to look at, and the refetch driven by
// restoreSession() / switchAccount will run the network half once the
// client is connected.
if (!jmapClient.isConnected) {
set({ loading: false });
return;
}
try {
const filter = buildJmapFilter(mailboxId, '', {});
const { ids, total } = await queryEmails(mailboxId, { limit, filter });
@@ -195,6 +211,7 @@ export const useEmailStore = create<EmailState>()(
loadMoreEmails: async () => {
const { currentMailboxId, emails, totalEmails, loading, searchQuery, filters } = get();
if (!currentMailboxId || loading || emails.length >= totalEmails) return;
if (!jmapClient.isConnected) return;
set({ loading: true });
try {
@@ -215,6 +232,7 @@ export const useEmailStore = create<EmailState>()(
refreshEmails: async () => {
const { currentMailboxId, searchQuery, filters, emails: existing } = get();
if (!currentMailboxId) return;
if (!jmapClient.isConnected) return;
// Keep existing emails visible while refreshing - swap atomically once
// the new list is ready so pull-to-refresh doesn't flash an empty list.
set({ loading: true, error: null });