Compare commits

...
7 Commits
14 changed files with 216 additions and 56 deletions
+12
View File
@@ -193,6 +193,18 @@ export default function App() {
return useNetworkStore.getState().init();
}, []);
// When the network flips back on while we're authenticated-but-offline
// (no live JMAP session), retry the session so the user lands back on
// live data without needing to relaunch.
React.useEffect(() => {
if (!isAuthenticated) return;
return useNetworkStore.subscribe((state, prev) => {
if (state.online && !prev.online && !useAuthStore.getState().session) {
void useAuthStore.getState().retrySession();
}
});
}, [isAuthenticated]);
React.useEffect(() => {
let cancelled = false;
void (async () => {
+1 -1
View File
@@ -1 +1 @@
0.1.17
0.1.20
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.17",
"version": "0.1.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.17",
"version": "0.1.20",
"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.17",
"version": "0.1.20",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+10 -7
View File
@@ -13,17 +13,20 @@ export async function uploadBlob(
encodeURIComponent(jmapClient.accountId),
);
// Read the source via the file-system API so this works for both `file://`
// (image picker, iOS document picker) and `content://` (Android SAF) URIs.
// Read via the file-system API so this works for both `file://` (image
// picker, iOS document picker) and `content://` (Android SAF) URIs. We pass
// the underlying ArrayBuffer rather than the Uint8Array view because some
// RN transports stringify typed-array bodies, which silently produces an
// empty/garbage upload.
const bytes = await new File(uri).bytes();
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': type,
Authorization: (jmapClient as any).authHeader,
'Content-Type': type || 'application/octet-stream',
Authorization: jmapClient.authHeader,
},
body: bytes as unknown as BodyInit,
body: bytes.buffer as ArrayBuffer,
});
if (!response.ok) {
@@ -66,8 +69,8 @@ export function getDownloadUrl(
if (!session) throw new Error('Not connected');
return session.downloadUrl
.replace('{accountId}', jmapClient.accountId)
.replace('{blobId}', blobId)
.replace('{accountId}', encodeURIComponent(jmapClient.accountId))
.replace('{blobId}', encodeURIComponent(blobId))
.replace('{name}', encodeURIComponent(name ?? 'download'))
.replace('{type}', encodeURIComponent(type ?? 'application/octet-stream'));
}
+53 -15
View File
@@ -53,7 +53,10 @@ export class JMAPClient {
// ── Authentication ────────────────────────────────────
private get authHeader(): string {
// Public so blob upload/download helpers (which can't go through the JMAP
// request body) can fetch the same Authorization header the rest of the
// client uses. The value is recomputed each access and never cached.
get authHeader(): string {
if (!this.credentials) throw new Error('No credentials');
if (this.credentials.accessToken) {
return `Bearer ${this.credentials.accessToken}`;
@@ -114,18 +117,38 @@ export class JMAPClient {
const stored = await SecureStore.getItemAsync(credentialsKey(accountId));
if (!stored) return false;
let creds: StoredCredentials;
try {
creds = JSON.parse(stored);
} catch {
// Corrupt entry — credentials can't be used. Caller should evict.
return false;
}
// Errors past this point propagate so callers can distinguish
// unrecoverable (AuthenticationError) from transient (NetworkError) and
// avoid clearing credentials when the server is just unreachable.
this.credentials = creds;
try {
const creds: StoredCredentials = JSON.parse(stored);
this.credentials = creds;
this.session = this.rewriteSessionUrls(
await this.fetchSession(creds.serverUrl),
creds.serverUrl,
);
this._accountId = this.resolveAccountId(this.session);
return true;
} catch {
return false;
} catch (err) {
// Don't leave a half-populated client behind; the caller needs to know
// the session is unavailable. Credentials stay in memory so a retry
// after the network comes back doesn't need a re-login.
this.session = null;
this._accountId = null;
if (err instanceof AuthenticationError) throw err;
// Anything else is treated as transport-level. Wrapping rather than
// rethrowing the raw fetch error gives callers a single check.
throw new NetworkError(
err instanceof Error ? err.message : 'Server unreachable',
);
}
this._accountId = this.resolveAccountId(this.session);
return true;
}
async clearAccountCredentials(accountId: string): Promise<void> {
@@ -161,17 +184,18 @@ export class JMAPClient {
// JMAP servers often self-report localhost/container-internal hostnames in
// apiUrl/downloadUrl/etc. that are unreachable from mobile clients (e.g.
// the Android emulator can't resolve the host's "localhost").
//
// Splits via plain string indexing rather than `new URL()` because RN's
// URL polyfill mutates inputs (appends trailing slashes, normalises
// characters) and would corrupt the RFC 6570 templates {accountId}/{blobId}
// before the caller has a chance to substitute values into them.
private rewriteSessionUrls(session: JMAPSession, serverUrl: string): JMAPSession {
const serverOrigin = this.extractOrigin(serverUrl);
const rewrite = (url: string | undefined): string | undefined => {
if (!url) return url;
try {
const parsed = new URL(url);
const server = new URL(serverUrl);
if (parsed.origin === server.origin) return url;
return server.origin + parsed.pathname + parsed.search + parsed.hash;
} catch {
return url;
}
const origin = this.extractOrigin(url);
if (!origin || !serverOrigin || origin === serverOrigin) return url;
return serverOrigin + url.slice(origin.length);
};
return {
...session,
@@ -182,6 +206,11 @@ export class JMAPClient {
};
}
private extractOrigin(url: string): string | null {
const m = url.match(/^(https?:\/\/[^/?#]+)/i);
return m ? m[1] : null;
}
// Reset in-memory state without touching persisted credentials (used on
// account switch so the active account's stored creds remain available).
reset(): void {
@@ -323,6 +352,15 @@ export class AuthenticationError extends Error {
}
}
// Server unreachable / transient transport failure. Callers should keep
// stored credentials and surface an offline state rather than logging out.
export class NetworkError extends Error {
constructor(message: string) {
super(message);
this.name = 'NetworkError';
}
}
export class RateLimitError extends Error {
retryAfterMs: number;
constructor(retryAfterMs: number) {
+1 -1
View File
@@ -134,7 +134,7 @@ export function connectEventSource(
: require('react-native-sse').default;
const es = new EventSourceImpl(url, {
headers: { Authorization: (jmapClient as any).authHeader },
headers: { Authorization: jmapClient.authHeader },
});
const handler = (event: any) => {
+4 -2
View File
@@ -487,7 +487,9 @@ function makeStyles(c: ThemePalette) {
top: 0, bottom: 0, left: 0,
width: '85%',
maxWidth: 340,
backgroundColor: '#262626', // matches webmail bg-secondary (dark mode)
// Mirror webmail's bg-secondary in both palettes - was hardcoded to the
// dark-mode value, which left light mode unreadable (dark bg + dark text).
backgroundColor: c.secondary,
borderRightWidth: 1,
borderRightColor: c.border,
},
@@ -537,7 +539,7 @@ function makeStyles(c: ThemePalette) {
marginHorizontal: spacing.sm,
marginTop: spacing.xs,
marginBottom: spacing.sm,
backgroundColor: '#1f1f1f',
backgroundColor: c.popover,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
@@ -191,7 +191,7 @@ function makeStyles(c: ThemePalette) {
left: 0,
width: '85%',
maxWidth: 340,
backgroundColor: '#262626',
backgroundColor: c.secondary,
borderRightWidth: 1,
borderRightColor: c.border,
},
@@ -228,7 +228,7 @@ function makeStyles(c: ThemePalette) {
top: 0, bottom: 0, left: 0,
width: '85%',
maxWidth: 340,
backgroundColor: '#262626',
backgroundColor: c.secondary,
borderRightWidth: 1,
borderRightColor: c.border,
},
+8 -15
View File
@@ -1,4 +1,3 @@
import { Platform } from 'react-native';
import { File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import { jmapClient } from '../api/jmap-client';
@@ -7,9 +6,7 @@ import { getDownloadUrl } from '../api/blob';
const RFC822 = 'message/rfc822';
function authHeader(): string {
const h = (jmapClient as unknown as { authHeader?: string }).authHeader;
if (!h) throw new Error('Not connected');
return h;
return jmapClient.authHeader;
}
function safeAttachmentName(name: string | undefined, type: string | undefined): string {
@@ -19,14 +16,10 @@ function safeAttachmentName(name: string | undefined, type: string | undefined):
return `attachment.${fallbackExt}`;
}
// On Android, sharing a `file://` URI from the cache directory throws a
// FileUriExposedException on modern OS versions; the system requires a
// FileProvider-backed `content://` URI instead. expo-file-system exposes that
// via `file.contentUri`, which is the only field iOS doesn't expose.
function shareableUri(file: { uri: string; contentUri?: string }): string {
if (Platform.OS === 'android' && file.contentUri) return file.contentUri;
return file.uri;
}
// expo-sharing only accepts `file://` URLs and rejects `content://` with
// "Only local file URLs are supported". On Android it then wraps the file
// itself with its bundled SharingFileProvider before launching the share
// intent, so we must NOT pre-translate to `file.contentUri`.
export async function shareAttachment(
blobId: string,
@@ -44,7 +37,7 @@ export async function shareAttachment(
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
await Sharing.shareAsync(shareableUri(downloaded), {
await Sharing.shareAsync(downloaded.uri, {
mimeType,
dialogTitle: filename,
});
@@ -71,7 +64,7 @@ export async function downloadAttachment(
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
await Sharing.shareAsync(shareableUri(downloaded), {
await Sharing.shareAsync(downloaded.uri, {
mimeType,
dialogTitle: filename,
});
@@ -99,7 +92,7 @@ export async function shareEmailEml(blobId: string, subject?: string): Promise<v
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
await Sharing.shareAsync(shareableUri(downloaded), {
await Sharing.shareAsync(downloaded.uri, {
mimeType: RFC822,
dialogTitle: 'Share email',
UTI: 'public.email-message',
+21
View File
@@ -11,6 +11,25 @@ import {
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';
interface PushPersistedSettings {
emailNotificationsEnabled?: boolean;
}
async function emailNotificationsAllowed(): Promise<boolean> {
try {
const raw = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY);
if (!raw) return true; // first launch: default to on
const parsed = JSON.parse(raw) as PushPersistedSettings;
return parsed.emailNotificationsEnabled !== false;
} catch {
return true;
}
}
interface ShowNotificationOptions {
notificationId: string;
@@ -33,6 +52,8 @@ interface BulwarkFcmNative {
// resolve so the native service can release its wake lock.
export async function pushBackgroundTask(_data: unknown): Promise<void> {
try {
if (!(await emailNotificationsAllowed())) return;
const accountId = await AsyncStorage.getItem(PUSH_ACCOUNT_ID_KEY);
if (!accountId) return;
+95 -7
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import { jmapClient, AuthenticationError } from '../api/jmap-client';
import { jmapClient, AuthenticationError, NetworkError } from '../api/jmap-client';
import type { JMAPSession } from '../api/types';
import { useAccountStore } from './account-store';
import { useEmailStore } from './email-store';
@@ -39,6 +39,7 @@ export interface AuthState {
logoutAll: () => Promise<void>;
switchAccount: (accountId: string) => Promise<void>;
restoreSession: () => Promise<boolean>;
retrySession: () => Promise<boolean>;
clearError: () => void;
}
@@ -286,15 +287,56 @@ export const useAuthStore = create<AuthState>((set, get) => ({
return false;
}
const ok = await jmapClient.loadAccount(target.id);
if (!ok) {
accountStore.removeAccount(target.id);
set({ isLoading: false, hasRestoredSession: true });
return false;
try {
const ok = await jmapClient.loadAccount(target.id);
if (!ok) {
// No stored credentials (or corrupt) — genuine logout.
accountStore.removeAccount(target.id);
set({ isLoading: false, hasRestoredSession: true });
return false;
}
} catch (err) {
if (err instanceof NetworkError) {
// Server unreachable. Keep credentials, mark account offline, and
// surface the cached UI so the user can still browse persisted
// mail / contacts / calendar. The login screen would lose their
// settings without recourse, which is the bug we're fixing here.
accountStore.setActiveAccount(target.id);
accountStore.updateAccount(target.id, {
isConnected: false,
hasError: true,
errorMessage: err.message,
});
set({
isAuthenticated: true,
isLoading: false,
hasRestoredSession: true,
error: null,
serverUrl: target.serverUrl,
username: target.username,
session: null,
accountId: null,
activeAccountId: target.id,
client: jmapClient,
});
return true;
}
if (err instanceof AuthenticationError) {
// Server reachable but credentials rejected — drop them.
await jmapClient.clearAccountCredentials(target.id).catch(() => undefined);
accountStore.removeAccount(target.id);
set({ isLoading: false, hasRestoredSession: true, error: 'Session expired' });
return false;
}
throw err;
}
accountStore.setActiveAccount(target.id);
accountStore.updateAccount(target.id, { isConnected: true, hasError: false });
accountStore.updateAccount(target.id, {
isConnected: true,
hasError: false,
errorMessage: undefined,
});
const session = jmapClient.currentSession!;
applyConnectedState(set, session, target.serverUrl, target.username, target.id);
@@ -309,5 +351,51 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
},
// Re-attempt session establishment for the currently active account
// without disturbing UI state on failure. Used by the network-recovery
// watcher and any explicit "retry" button. Idempotent: returns true if
// a session is already live.
retrySession: async () => {
const { activeAccountId, session } = get();
if (!activeAccountId) return false;
if (session) return true;
const accountStore = useAccountStore.getState();
const target = accountStore.getAccountById(activeAccountId);
if (!target) return false;
try {
const ok = await jmapClient.loadAccount(activeAccountId);
if (!ok) return false;
accountStore.updateAccount(activeAccountId, {
isConnected: true,
hasError: false,
errorMessage: undefined,
});
const fresh = jmapClient.currentSession!;
applyConnectedState(set, fresh, target.serverUrl, target.username, activeAccountId);
refetchFeatureStores();
return true;
} catch (err) {
if (err instanceof AuthenticationError) {
// Now we know the credentials are bad — fall back to logout flow.
await jmapClient.clearAccountCredentials(activeAccountId).catch(() => undefined);
accountStore.removeAccount(activeAccountId);
set({
isAuthenticated: false,
isLoading: false,
hasRestoredSession: true,
error: 'Session expired',
serverUrl: null,
username: null,
session: null,
accountId: null,
activeAccountId: null,
client: null,
});
}
// NetworkError or anything else: stay where we are.
return false;
}
},
clearError: () => set({ error: null }),
}));
+6 -3
View File
@@ -151,7 +151,8 @@ export const useEmailStore = create<EmailState>()(
});
try {
const filter = buildJmapFilter(mailboxId, '', {});
const { ids, total } = await queryEmails(mailboxId, { limit: 50, filter });
const limit = useSettingsStore.getState().emailsPerPage;
const { ids, total } = await queryEmails(mailboxId, { limit, filter });
const emails = ids.length > 0 ? await fetchEmails(ids) : [];
set({ emails, totalEmails: total, loading: false });
} catch (err) {
@@ -167,9 +168,10 @@ export const useEmailStore = create<EmailState>()(
set({ loading: true });
try {
const filter = buildJmapFilter(currentMailboxId, searchQuery, filters);
const limit = useSettingsStore.getState().emailsPerPage;
const { ids } = await queryEmails(currentMailboxId, {
position: emails.length,
limit: 50,
limit,
filter,
});
const newEmails = ids.length > 0 ? await fetchEmails(ids) : [];
@@ -187,7 +189,8 @@ export const useEmailStore = create<EmailState>()(
set({ loading: true, error: null });
try {
const filter = buildJmapFilter(currentMailboxId, searchQuery, filters);
const { ids, total } = await queryEmails(currentMailboxId, { limit: 50, filter });
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 });
} catch (err) {