Compare commits

..
19 Commits
Author SHA1 Message Date
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
Linus Rath 886a64171d chore: update version number to 0.1.27 2026-05-08 13:18:24 +02:00
Linus Rath 760bf01f4d fix: defer expo-document-picker require to avoid native-module crash 2026-05-08 13:18:17 +02:00
Linus Rath b6d87093b9 chore: update version number to 0.1.26 2026-05-08 10:24:43 +02:00
Linus Rath 2a3b1f90dc feat: add upload, preview, download, selection, folder creation 2026-05-08 10:24:36 +02:00
Linus Rath 2ed9c29bd5 fix: clear status bar inset on OfflineCacheBanner 2026-05-08 01:36:49 +02:00
Linus Rath 455f26e90a chore: update version number to 0.1.25 2026-05-08 01:03:20 +02:00
Linus Rath e9b93bc6e5 fix: import ByteArrayOutputStream in BulwarkFcmModule 2026-05-08 01:03:11 +02:00
Linus Rath c21661ac7a feat: finish Files tab with folder nav, grid view, and refresh 2026-05-08 00:57:51 +02:00
Linus Rath 00be851611 chore: update version number to 0.1.24 2026-05-08 00:51:23 +02:00
Linus Rath 71ff8930a2 fix: apply unread/starred filters in mailbox queries 2026-05-08 00:50:27 +02:00
Linus Rath 29b06a3a4f security: fix push SSE breakage, harden APK updates and email rendering 2026-05-05 22:06:07 +02:00
Linus Rath 2911b8e7ac chore: update version number to 0.1.23 2026-05-03 23:23:15 +02:00
Linus Rath 3d4779a689 feat: enhance offline email handling with caching and mailbox seeding 2026-05-03 19:05:30 +02:00
36 changed files with 2208 additions and 341 deletions
+27 -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';
@@ -51,8 +51,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,
@@ -261,11 +274,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 +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();
@@ -315,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.22
0.1.30
+2 -4
View File
@@ -2,11 +2,9 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
@@ -19,6 +19,7 @@ import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.WritableMap
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
import com.google.firebase.messaging.FirebaseMessaging
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.URL
import kotlin.concurrent.thread
@@ -64,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.
@@ -72,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)
}
@@ -93,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 {
@@ -100,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,
@@ -148,7 +152,36 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
conn.readTimeout = 3000
conn.instanceFollowRedirects = true
conn.setRequestProperty("User-Agent", "bulwark-mobile")
conn.inputStream.use { BitmapFactory.decodeStream(it) }
// Cap the response to MAX_FAVICON_BYTES so a hostile / misconfigured
// server can't OOM the headless task with a multi-megabyte image.
// We also sample down to ~256x256 since it'll only render at 192px.
val stream = conn.inputStream
val bytes = stream.use { input ->
val limit = MAX_FAVICON_BYTES
val buf = ByteArrayOutputStream()
val chunk = ByteArray(8 * 1024)
var total = 0
while (true) {
val n = input.read(chunk)
if (n <= 0) break
total += n
if (total > limit) return@use null
buf.write(chunk, 0, n)
}
buf.toByteArray()
} ?: return null
val opts = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
var sample = 1
while ((opts.outWidth / sample) > 384 || (opts.outHeight / sample) > 384) {
sample *= 2
}
val decodeOpts = BitmapFactory.Options().apply {
inSampleSize = sample
}
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOpts)
} catch (_: Exception) {
null
}
@@ -167,6 +200,9 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
fun removeListeners(count: Int) {}
companion object {
// 1 MiB is plenty for a favicon; anything bigger is a sign of trouble.
private const val MAX_FAVICON_BYTES = 1 * 1024 * 1024
@Volatile private var currentInstance: BulwarkFcmModule? = null
fun emit(eventName: String, params: WritableMap?) {
@@ -17,17 +17,36 @@ object NotificationTapStore {
return value
}
// The activity is exported (required by the LAUNCHER intent filter), so
// any third-party app on the device can craft a starting intent that
// includes these extras. Reject anything that doesn't look like the IDs
// 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
val emailId = extras.getString(EXTRA_EMAIL_ID) ?: return null
val threadId = extras.getString(EXTRA_THREAD_ID) ?: return null
val subject = extras.getString(EXTRA_SUBJECT)
val payload = TapPayload(emailId, threadId, subject)
val emailId = extras.getString(EXTRA_EMAIL_ID)?.takeIf { ID_PATTERN.matches(it) }
?: return null
val threadId = extras.getString(EXTRA_THREAD_ID)?.takeIf { ID_PATTERN.matches(it) }
?: return null
// Subject is human-readable text and may legitimately contain anything;
// 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 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
}
@@ -35,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"
}
+12 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.22",
"version": "0.1.30",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.22",
"version": "0.1.30",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
@@ -17,6 +17,7 @@
"@react-navigation/native-stack": "^7.14.10",
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
"expo-intent-launcher": "~13.0.8",
@@ -5216,6 +5217,15 @@
"react-native": "*"
}
},
"node_modules/expo-document-picker": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
"integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-file-system": {
"version": "19.0.21",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.22",
"version": "0.1.30",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
@@ -10,6 +10,7 @@
"web": "expo start --web",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"bump": "node scripts/bump-version.mjs",
"sync-locales": "node scripts/sync-locales.mjs"
},
@@ -22,6 +23,7 @@
"@react-navigation/native-stack": "^7.14.10",
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
"expo-intent-launcher": "~13.0.8",
+8 -7
View File
@@ -4,6 +4,7 @@ vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
getMaxObjectsInGet: () => 500,
},
}));
@@ -41,7 +42,10 @@ describe('calendar operations', () => {
});
describe('queryEvents', () => {
it('should query events within date range', async () => {
it('should send a CalendarEvent/query without server-side filter', async () => {
// Stalwart rejects inCalendars/after/before, so we fetch unfiltered and
// filter client-side. Asserting on the absence of `filter` keeps that
// contract pinned.
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']],
});
@@ -50,12 +54,9 @@ describe('calendar operations', () => {
expect(result).toEqual(['ev1', 'ev2']);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({
inCalendars: ['cal-1'],
after: '2026-03-01T00:00:00Z',
before: '2026-03-31T23:59:59Z',
types: ['Event'],
});
expect(call[0]).toBe('CalendarEvent/query');
expect(call[1].filter).toBeUndefined();
expect(call[1].accountId).toBe('acc-1');
});
});
+3 -2
View File
@@ -4,6 +4,7 @@ vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
getMaxObjectsInGet: () => 500,
},
}));
@@ -50,14 +51,14 @@ describe('contacts operations', () => {
expect(result).toEqual(['c1', 'c2']);
});
it('should use default empty filter', async () => {
it('should omit filter when none provided', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/query', { ids: [] }, '0']],
});
await queryContacts();
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({});
expect(call[1].filter).toBeUndefined();
});
});
+20 -2
View File
@@ -90,6 +90,24 @@ describe('email operations', () => {
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({ inMailbox: 'mb-1', hasKeyword: '$flagged' });
});
it('should AND-wrap a FilterOperator instead of spreading it', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['Email/query', { ids: [], total: 0 }, '0']],
});
const userFilter = {
operator: 'AND',
conditions: [{ inMailbox: 'mb-1' }, { notKeyword: '$seen' }],
};
await queryEmails('mb-1', { filter: userFilter });
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({
operator: 'AND',
conditions: [{ inMailbox: 'mb-1' }, userFilter],
});
});
});
describe('getEmails', () => {
@@ -197,7 +215,7 @@ describe('email operations', () => {
expect(result).toEqual(['e1']);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({ text: 'test query' });
expect(call[1].filter).toEqual({ text: 'test* query*' });
});
it('should include mailbox filter if specified', async () => {
@@ -208,7 +226,7 @@ describe('email operations', () => {
await searchEmails('query', 'mb-1');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({ text: 'query', inMailbox: 'mb-1' });
expect(call[1].filter).toEqual({ text: 'query*', inMailbox: 'mb-1' });
});
});
+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,
+13 -1
View File
@@ -93,10 +93,22 @@ export async function queryEmails(
},
): Promise<{ ids: string[]; total: number }> {
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 res = await jmapClient.request([
['Email/query', {
accountId,
filter: { inMailbox: mailboxId, ...options?.filter },
filter,
sort: options?.sort ?? [{ property: 'receivedAt', isAscending: false }],
position: options?.position ?? 0,
limit: options?.limit ?? 50,
+157 -8
View File
@@ -1,19 +1,68 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { FileNode } from './types';
import { getDownloadUrl, uploadBlob, uploadBytes } from './blob';
const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL];
const USING = [CAPABILITIES.CORE, CAPABILITIES.FILES];
export async function getFileNodes(parentId?: string | null): Promise<FileNode[]> {
// Stalwart and other JMAP servers use one of these strings on FileNode.type to
// mark a directory; everything else is a MIME type.
const DIRECTORY_TYPES = new Set([
'd',
'application/x-directory',
'text/directory',
'httpd/unix-directory',
'inode/directory',
]);
export function isDirectory(node: Pick<FileNode, 'type'>): boolean {
const t = node.type;
if (!t) return false;
return DIRECTORY_TYPES.has(t) || t.includes('directory');
}
// 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 filter: Record<string, unknown> = {};
if (parentId !== undefined) filter.parentId = parentId;
const res = await jmapClient.request(
[['FileNode/get', { accountId, filter }, '0']],
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
USING,
);
return res.methodResponses[0][1].list;
return (res.methodResponses[0][1].list ?? []) as FileNode[];
}
export async function createFileNode(
@@ -27,13 +76,113 @@ export async function createFileNode(
}, '0']],
USING,
);
return res.methodResponses[0][1].created['new-file'];
const created = res.methodResponses[0][1].created?.['new-file'];
if (!created) {
const err = res.methodResponses[0][1].notCreated?.['new-file'];
throw new Error(err?.description || 'Create failed');
}
return created;
}
export async function createFolder(
displayName: string,
currentPath: readonly string[],
): Promise<FileNode> {
// 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(
id: string,
updates: Partial<Pick<FileNode, 'name' | 'parentId'>>,
): Promise<void> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['FileNode/set', { accountId, update: { [id]: updates } }, '0']],
USING,
);
const notUpdated = res.methodResponses[0][1].notUpdated?.[id];
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;
await jmapClient.request(
[['FileNode/set', { accountId, destroy: ids }, '0']],
USING,
);
}
// 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);
}
export async function uploadFileNode(
uri: string,
displayName: string,
mimeType: string,
currentPath: readonly string[],
): Promise<FileNode> {
const blob = await uploadBlob(uri, mimeType);
const fullName = pathToPrefix(currentPath) + displayName;
return createFileNode({
name: fullName,
type: blob.type || mimeType || 'application/octet-stream',
blobId: blob.blobId,
size: blob.size,
});
}
+27 -5
View File
@@ -201,13 +201,35 @@ export function startPolling(
return () => clearInterval(timer);
}
export interface StartPushOptions {
onStateChange: StateChangeHandler;
onError?: (error: Error) => void;
}
/**
* Start real-time updates, preferring SSE with polling fallback.
* Start real-time updates, preferring SSE with polling fallback. The `_client`
* argument is unused today (the underlying event source uses the singleton
* `jmapClient`) but is part of the call signature so the host App can pass
* the active client reference and trigger React re-runs on account switch.
*/
export function startPushUpdates(
onStateChange: StateChangeHandler,
_client: unknown,
opts: StartPushOptions,
): () => void {
const cleanup = connectEventSource(onStateChange);
if (cleanup) return cleanup;
return startPolling(onStateChange);
const onError = opts.onError;
const safeOnStateChange: StateChangeHandler = (change) => {
try {
opts.onStateChange(change);
} catch (err) {
if (onError) onError(err instanceof Error ? err : new Error(String(err)));
}
};
try {
const cleanup = connectEventSource(safeOnStateChange);
if (cleanup) return cleanup;
return startPolling(safeOnStateChange);
} catch (err) {
if (onError) onError(err instanceof Error ? err : new Error(String(err)));
return () => undefined;
}
}
+2 -1
View File
@@ -429,7 +429,7 @@ export interface FileNode {
name: string;
parentId?: string | null;
type: string;
blobId?: string;
blobId?: string | null;
size?: number;
created?: string;
updated?: string;
@@ -465,4 +465,5 @@ export const CAPABILITIES = {
CALENDARS: 'urn:ietf:params:jmap:calendars',
SIEVE: 'urn:ietf:params:jmap:sieve',
QUOTA: 'urn:ietf:params:jmap:quota',
FILES: 'urn:ietf:params:jmap:filenode',
} as const;
+48
View File
@@ -13,6 +13,45 @@ export interface LatestRelease {
publishedAt: string;
body: string;
apkAsset: ReleaseAsset | null;
// Lower-case hex SHA-256 of the APK, parsed from a `*.apk.sha256` companion
// asset or from the release body. Null when the release didn't publish one;
// the installer falls back to relying solely on Android's package signing
// check in that case (still strong, but less defense-in-depth).
apkSha256: string | null;
}
const SHA256_RE = /\b([a-f0-9]{64})\b/i;
function extractSha256(body: string, apkName: string | undefined): string | null {
if (!body) return null;
// Prefer a line that explicitly references the APK so we don't pick up the
// hash of an unrelated asset.
const lines = body.split(/\r?\n/);
if (apkName) {
for (const line of lines) {
if (line.includes(apkName)) {
const m = SHA256_RE.exec(line);
if (m) return m[1].toLowerCase();
}
}
}
// Fallback: a single SHA256 anywhere in the body — common when the release
// notes just list one checksum.
const m = SHA256_RE.exec(body);
return m ? m[1].toLowerCase() : null;
}
async function fetchCompanionSha256(asset: ReleaseAsset | undefined): Promise<string | null> {
if (!asset) return null;
try {
const res = await fetch(asset.browser_download_url);
if (!res.ok) return null;
const text = await res.text();
const m = SHA256_RE.exec(text);
return m ? m[1].toLowerCase() : null;
} catch {
return null;
}
}
export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<LatestRelease | null> {
@@ -32,6 +71,14 @@ export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<La
assets: ReleaseAsset[];
};
const apkAsset = json.assets.find((a) => a.name.endsWith('.apk')) ?? null;
const sha256Asset = apkAsset
? json.assets.find(
(a) => a.name === `${apkAsset.name}.sha256` || a.name === `${apkAsset.name}.SHA256`,
)
: undefined;
const apkSha256 =
(await fetchCompanionSha256(sha256Asset)) ??
extractSha256(json.body || '', apkAsset?.name);
return {
tag: json.tag_name,
name: json.name || json.tag_name,
@@ -39,5 +86,6 @@ export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<La
publishedAt: json.published_at,
body: json.body || '',
apkAsset,
apkSha256,
};
}
+1 -1
View File
@@ -297,7 +297,7 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
<View style={[styles.webContainer, { height }]}>
<WebView
ref={webviewRef}
originWhitelist={['about:*']}
originWhitelist={['about:blank']}
source={source}
javaScriptEnabled
injectedJavaScript={HEIGHT_REPORTER}
+12 -1
View File
@@ -3,8 +3,10 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { CloudDownload, X } from 'lucide-react-native';
import { useOfflineCacheStore } from '../stores/offline-cache-store';
import { useUpdatesStore } from '../stores/updates-store';
import { formatBytes } from '../lib/offline-sync';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -12,9 +14,18 @@ import { useColors } from '../theme/colors';
export function OfflineCacheBanner(): React.ReactElement | null {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const sync = useOfflineCacheStore((s) => s.sync);
const requestAbort = useOfflineCacheStore((s) => s.requestAbort);
const resetSync = useOfflineCacheStore((s) => s.resetSync);
// When UpdateBanner is stacked above us it already absorbs the status-bar
// inset, so we only add it when we're the topmost banner.
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
const updateBannerVisible =
hasUpdate() && cachedLatest?.apkAsset != null && dismissedTag !== cachedLatest.tag;
const topInset = updateBannerVisible ? 0 : insets.top;
const [hideTimer, setHideTimer] = React.useState<ReturnType<typeof setTimeout> | null>(null);
// Auto-dismiss the "done" state after a few seconds so the bar isn't a
@@ -71,7 +82,7 @@ export function OfflineCacheBanner(): React.ReactElement | null {
const isError = sync.phase === 'error';
return (
<View style={[styles.banner, isError && styles.bannerError]}>
<View style={[styles.banner, isError && styles.bannerError, { paddingTop: spacing.sm + topInset }]}>
<CloudDownload size={16} color={c.primaryForeground} />
<View style={{ flex: 1 }}>
<Text style={styles.title}>{title}</Text>
+19 -7
View File
@@ -263,20 +263,30 @@ function buildEditorHtml(opts: {
},
insertLink: function (url, label) {
editor.focus();
// Reject anything that isn't a safe scheme or an absolute path, so the
// user can't accidentally (or via a malicious paste) ship a
// javascript:/data: link to their recipient.
var safeSchemeRe = /^(https?:|mailto:|tel:|sms:|\/|\?|#)/i;
var trimmed = (url || '').trim();
if (!trimmed || !safeSchemeRe.test(trimmed)) return;
var sel = window.getSelection();
var hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
if (hasSelection) {
try { document.execCommand('createLink', false, url); } catch (e) {}
try { document.execCommand('createLink', false, trimmed); } catch (e) {}
// execCommand doesn't set rel; tag every anchor that points at this URL.
Array.prototype.forEach.call(editor.getElementsByTagName('a'), function (a) {
if (a.getAttribute('href') === url) {
if (a.getAttribute('href') === trimmed) {
a.setAttribute('rel', 'noopener noreferrer nofollow');
}
});
} else {
var text = label && label.trim() ? label : url;
var text = label && label.trim() ? label : trimmed;
var safe = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var safeUrl = url.replace(/"/g, '&quot;');
var safeUrl = trimmed
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
try { document.execCommand('insertHTML', false, html); } catch (e) {}
}
@@ -409,7 +419,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
<View style={[styles.container, { height }]}>
<WebView
ref={webViewRef}
originWhitelist={['about:*']}
originWhitelist={['about:blank']}
source={{ html }}
onMessage={onMessage}
javaScriptEnabled
@@ -433,9 +443,11 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
style={styles.webview}
containerStyle={styles.webviewContainer}
onShouldStartLoadWithRequest={(request) => {
// Allow only the initial about:blank doc + data URIs we emit ourselves.
// Allow only the initial about:blank doc + data URIs we emit
// ourselves. Reject `about:*` (e.g. about:srcdoc) and every other
// scheme so a paste-injected anchor can't navigate the editor.
const url = request.url;
return !url || url === 'about:blank' || url.startsWith('data:') || url.startsWith('about:');
return !url || url === 'about:blank' || url.startsWith('data:');
}}
/>
</View>
@@ -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({
Binary file not shown.
+84 -2
View File
@@ -3,12 +3,18 @@ import { File, Paths } from 'expo-file-system';
import * as FileSystemLegacy from 'expo-file-system/legacy';
import * as IntentLauncher from 'expo-intent-launcher';
import type { ReleaseAsset } from '../api/updates';
import { Sha256 } from './sha256';
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
const FLAG_ACTIVITY_NEW_TASK = 0x10000000;
// Bytes per file-read chunk while computing the post-download SHA-256. Larger
// chunks are faster but balloon the JS string buffer (base64 reads on this
// platform). 256 KiB keeps memory bounded while still amortising the bridge.
const HASH_CHUNK_BYTES = 256 * 1024;
export interface InstallProgress {
phase: 'downloading' | 'installing';
phase: 'downloading' | 'verifying' | 'installing';
/** 0-1 during download; undefined for installing phase */
progress?: number;
/** Bytes received during current download */
@@ -19,10 +25,51 @@ export interface InstallProgress {
export type InstallProgressListener = (p: InstallProgress) => void;
export interface InstallParams {
asset: ReleaseAsset;
/** Optional lower-case hex SHA-256. When present, mismatched downloads abort. */
expectedSha256?: string | null;
}
function base64ToBytes(b64: string): Uint8Array {
const bin = globalThis.atob ? globalThis.atob(b64) : atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function hashFile(uri: string, totalBytes: number, onProgress?: InstallProgressListener): Promise<string> {
const hasher = new Sha256();
let offset = 0;
while (offset < totalBytes || totalBytes === 0) {
const length = Math.min(HASH_CHUNK_BYTES, totalBytes ? totalBytes - offset : HASH_CHUNK_BYTES);
const chunkB64 = await FileSystemLegacy.readAsStringAsync(uri, {
encoding: FileSystemLegacy.EncodingType.Base64,
position: offset,
length,
});
if (!chunkB64) break;
const bytes = base64ToBytes(chunkB64);
if (bytes.length === 0) break;
hasher.update(bytes);
offset += bytes.length;
if (totalBytes > 0) {
onProgress?.({ phase: 'verifying', progress: offset / totalBytes });
}
if (bytes.length < length) break;
}
return hasher.digestHex();
}
export async function downloadAndInstallApk(
asset: ReleaseAsset,
params: InstallParams | ReleaseAsset,
onProgress?: InstallProgressListener,
): Promise<void> {
// Backwards-compat: callers used to pass the asset directly. Promote to the
// params shape so the verification path is opt-in by passing a checksum.
const { asset, expectedSha256 }: InstallParams =
'asset' in params ? params : { asset: params, expectedSha256: null };
if (Platform.OS !== 'android') {
throw new Error('APK install is only supported on Android');
}
@@ -53,6 +100,33 @@ export async function downloadAndInstallApk(
throw new Error('APK download failed');
}
// Size sanity check. GitHub's `assets[].size` is signed by their TLS cert,
// so a byte-count mismatch is a strong signal the file was truncated /
// tampered with in transport. Cheap to compute, fail-fast.
const info = await FileSystemLegacy.getInfoAsync(result.uri);
const actualSize = (info as { size?: number }).size ?? 0;
if (asset.size > 0 && actualSize !== asset.size) {
await tryDelete(result.uri);
throw new Error(
`APK size mismatch: expected ${asset.size} bytes, got ${actualSize}`,
);
}
// SHA-256 verification when the release publishes one. Without it, we still
// get Android's package-signer check at install time — which rejects an APK
// signed with a different key — but that fails late (after the user sees a
// confusing system dialog). Bailing here surfaces the problem cleanly.
if (expectedSha256) {
onProgress?.({ phase: 'verifying', progress: 0 });
const actual = await hashFile(result.uri, actualSize || asset.size, onProgress);
if (actual.toLowerCase() !== expectedSha256.toLowerCase()) {
await tryDelete(result.uri);
throw new Error(
`APK checksum mismatch: expected ${expectedSha256}, got ${actual}`,
);
}
}
onProgress?.({ phase: 'installing' });
const contentUri = await FileSystemLegacy.getContentUriAsync(result.uri);
@@ -64,6 +138,14 @@ export async function downloadAndInstallApk(
});
}
async function tryDelete(uri: string): Promise<void> {
try {
await FileSystemLegacy.deleteAsync(uri, { idempotent: true });
} catch {
/* best effort */
}
}
/**
* Open the system "Install unknown apps" settings page for this app, so the
* user can grant permission to install APKs from the package installer.
+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
+131
View File
@@ -0,0 +1,131 @@
// Minimal SHA-256 implementation. Used to verify APK update downloads against
// the checksum published in the GitHub release body — the JS runtime has no
// built-in `crypto.subtle.digest` and we don't currently depend on
// `expo-crypto`. Public-domain algorithm (FIPS 180-4); typical sub-second per
// MB on a modern phone, which is fine for the rare update flow.
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
function rotr(x: number, n: number): number {
return (x >>> n) | (x << (32 - n));
}
export class Sha256 {
private h = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
]);
private buffer = new Uint8Array(64);
private bufferLen = 0;
private byteCount = 0;
update(bytes: Uint8Array): void {
let i = 0;
this.byteCount += bytes.length;
while (i < bytes.length) {
const room = 64 - this.bufferLen;
const chunk = Math.min(room, bytes.length - i);
this.buffer.set(bytes.subarray(i, i + chunk), this.bufferLen);
this.bufferLen += chunk;
i += chunk;
if (this.bufferLen === 64) {
this.process(this.buffer);
this.bufferLen = 0;
}
}
}
digestHex(): string {
// Final block: append 0x80, pad with zeros, then 8-byte big-endian length.
const totalBits = this.byteCount * 8;
this.buffer[this.bufferLen++] = 0x80;
if (this.bufferLen > 56) {
while (this.bufferLen < 64) this.buffer[this.bufferLen++] = 0;
this.process(this.buffer);
this.bufferLen = 0;
}
while (this.bufferLen < 56) this.buffer[this.bufferLen++] = 0;
// 64-bit length, big-endian. We can't represent >2^53 - JS bitops are
// 32-bit, so split into hi/lo words.
const hi = Math.floor(totalBits / 0x100000000);
const lo = totalBits >>> 0;
this.buffer[56] = (hi >>> 24) & 0xff;
this.buffer[57] = (hi >>> 16) & 0xff;
this.buffer[58] = (hi >>> 8) & 0xff;
this.buffer[59] = hi & 0xff;
this.buffer[60] = (lo >>> 24) & 0xff;
this.buffer[61] = (lo >>> 16) & 0xff;
this.buffer[62] = (lo >>> 8) & 0xff;
this.buffer[63] = lo & 0xff;
this.process(this.buffer);
let out = '';
for (let i = 0; i < 8; i++) {
out += this.h[i].toString(16).padStart(8, '0');
}
return out;
}
private process(block: Uint8Array): void {
const w = new Uint32Array(64);
for (let i = 0; i < 16; i++) {
w[i] =
(block[i * 4] << 24) |
(block[i * 4 + 1] << 16) |
(block[i * 4 + 2] << 8) |
block[i * 4 + 3];
}
for (let i = 16; i < 64; i++) {
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
}
let a = this.h[0], b = this.h[1], c = this.h[2], d = this.h[3];
let e = this.h[4], f = this.h[5], g = this.h[6], hh = this.h[7];
for (let i = 0; i < 64; i++) {
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
const ch = (e & f) ^ (~e & g);
const temp1 = (hh + S1 + ch + K[i] + w[i]) | 0;
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
const mj = (a & b) ^ (a & c) ^ (b & c);
const temp2 = (S0 + mj) | 0;
hh = g;
g = f;
f = e;
e = (d + temp1) | 0;
d = c;
c = b;
b = a;
a = (temp1 + temp2) | 0;
}
this.h[0] = (this.h[0] + a) | 0;
this.h[1] = (this.h[1] + b) | 0;
this.h[2] = (this.h[2] + c) | 0;
this.h[3] = (this.h[3] + d) | 0;
this.h[4] = (this.h[4] + e) | 0;
this.h[5] = (this.h[5] + f) | 0;
this.h[6] = (this.h[6] + g) | 0;
this.h[7] = (this.h[7] + hh) | 0;
}
}
export function sha256Hex(bytes: Uint8Array): string {
const h = new Sha256();
h.update(bytes);
return h.digestHex();
}
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,12 +1,15 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Constants from 'expo-constants';
import { Eye, EyeOff, X } from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { Button, Input } from '../components';
import { useAuthStore } from '../stores/auth-store';
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
interface LoginScreenProps {
onLogin?: () => void;
isAddMode?: boolean;
@@ -156,7 +159,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
{/* Footer */}
<View style={styles.footer}>
<Text style={styles.footerText}>Bulwark Mobile v0.0.1</Text>
<Text style={styles.footerText}>Bulwark Mobile v{APP_VERSION}</Text>
<Text style={styles.footerLink}>Privacy Policy</Text>
</View>
</View>
+14 -7
View File
@@ -210,14 +210,20 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header - matches webmail mobile: h-14, border-b, back button + icon + title */}
{/* Header - matches webmail mobile: h-14, border-b, back button + icon + title.
Settings is a tab root, so the back button is only rendered when a
parent passes onBack (it'd otherwise lead nowhere). */}
<View style={styles.header}>
<Pressable
onPress={onBack}
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
>
<ArrowLeft size={20} color={c.text} />
</Pressable>
{onBack ? (
<Pressable
onPress={onBack}
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
>
<ArrowLeft size={20} color={c.text} />
</Pressable>
) : (
<View style={styles.headerLeftSpacer} />
)}
<Settings size={20} color={c.mutedForeground} />
<Text style={styles.headerTitle}>{t('settings.title', 'Settings')}</Text>
</View>
@@ -310,6 +316,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.md,
},
headerBackBtnPressed: { backgroundColor: c.accent },
headerLeftSpacer: { width: spacing.sm },
headerTitle: { ...typography.h3, color: c.text },
scrollArea: { flex: 1 },
+58 -20
View File
@@ -1,42 +1,63 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('expo-secure-store', () => ({
setItemAsync: vi.fn(),
getItemAsync: vi.fn(),
deleteItemAsync: vi.fn(),
}));
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
connect: vi.fn(),
logout: vi.fn(),
restoreSession: vi.fn(),
loadAccount: vi.fn(),
consumeLegacyCredentials: vi.fn(async () => null),
clearAccountCredentials: vi.fn(async () => undefined),
clearAllCredentials: vi.fn(async () => undefined),
reset: vi.fn(),
accountId: 'acc-1',
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
username: 'user',
serverUrl: 'https://mail.example.com',
},
AuthenticationError: class AuthenticationError extends Error {
constructor(msg: string) { super(msg); this.name = 'AuthenticationError'; }
},
NetworkError: class NetworkError extends Error {
constructor(msg: string) { super(msg); this.name = 'NetworkError'; }
},
}));
vi.mock('../../lib/push-notifications', () => ({
teardownPushNotifications: vi.fn(async () => undefined),
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
}));
import { jmapClient } from '../../api/jmap-client';
import { useAuthStore } from '../auth-store';
import { useAccountStore } from '../account-store';
const mockConnect = jmapClient.connect as ReturnType<typeof vi.fn>;
const mockLogout = jmapClient.logout as ReturnType<typeof vi.fn>;
const mockRestore = jmapClient.restoreSession as ReturnType<typeof vi.fn>;
const mockLoadAccount = jmapClient.loadAccount as ReturnType<typeof vi.fn>;
function resetAccountStore(): void {
useAccountStore.setState({
accounts: [],
activeAccountId: null,
defaultAccountId: null,
});
}
beforeEach(() => {
vi.clearAllMocks();
// Reset store state
resetAccountStore();
useAuthStore.setState({
isAuthenticated: false,
isLoading: false,
hasRestoredSession: false,
error: null,
serverUrl: null,
username: null,
session: null,
accountId: null,
activeAccountId: null,
client: null,
});
});
@@ -83,7 +104,7 @@ describe('auth-store', () => {
});
describe('logout', () => {
it('should reset all state', async () => {
it('should reset all state when no other accounts remain', async () => {
useAuthStore.setState({ isAuthenticated: true, serverUrl: 'x', username: 'y' });
mockLogout.mockResolvedValue(undefined);
@@ -97,23 +118,40 @@ describe('auth-store', () => {
});
describe('restoreSession', () => {
it('should restore on success', async () => {
mockRestore.mockResolvedValue(true);
it('should return false when there is no registered account', async () => {
const restored = await useAuthStore.getState().restoreSession();
expect(restored).toBe(false);
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(useAuthStore.getState().hasRestoredSession).toBe(true);
});
it('should restore when loadAccount succeeds for the active account', async () => {
useAccountStore.setState({
accounts: [
{
id: 'acc-1',
serverUrl: 'https://mail.example.com',
username: 'user',
displayName: 'user',
email: 'user',
avatarColor: '#000',
lastLoginAt: 0,
isConnected: false,
hasError: false,
isDefault: true,
},
],
activeAccountId: 'acc-1',
defaultAccountId: 'acc-1',
});
mockLoadAccount.mockResolvedValue(true);
const restored = await useAuthStore.getState().restoreSession();
expect(restored).toBe(true);
expect(useAuthStore.getState().isAuthenticated).toBe(true);
});
it('should return false when no session to restore', async () => {
mockRestore.mockResolvedValue(false);
const restored = await useAuthStore.getState().restoreSession();
expect(restored).toBe(false);
expect(useAuthStore.getState().isAuthenticated).toBe(false);
});
});
describe('clearError', () => {
+12 -4
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) {
+74 -4
View File
@@ -1,7 +1,8 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Email, Mailbox } from '../api/types';
import type { Email, Mailbox, StateChange } from '../api/types';
import { jmapClient } from '../api/jmap-client';
import {
getMailboxes as fetchMailboxes,
queryEmails,
@@ -57,6 +58,7 @@ export interface EmailState {
selectMailbox: (mailboxId: string) => Promise<void>;
loadMoreEmails: () => Promise<void>;
refreshEmails: () => Promise<void>;
handleStateChange: (change: StateChange) => Promise<void>;
getEmailDetail: (id: string) => Promise<Email>;
markRead: (emailId: string) => Promise<void>;
markUnread: (emailId: string) => Promise<void>;
@@ -150,15 +152,43 @@ export const useEmailStore = create<EmailState>()(
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);
}
}
try {
const filter = buildJmapFilter(mailboxId, '', {});
const limit = useSettingsStore.getState().emailsPerPage;
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);
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load emails' });
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'),
});
}
},
@@ -183,7 +213,7 @@ export const useEmailStore = create<EmailState>()(
},
refreshEmails: async () => {
const { currentMailboxId, searchQuery, filters } = get();
const { currentMailboxId, searchQuery, filters, emails: existing } = get();
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.
@@ -196,10 +226,50 @@ export const useEmailStore = create<EmailState>()(
set({ emails, totalEmails: total, loading: false });
} 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 (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) {
set({ emails: cached, totalEmails: cached.length, loading: false, error: null });
return;
}
}
} catch (cacheErr) {
console.warn('[email-store] refresh cache fallback failed:', cacheErr);
}
}
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load emails' });
}
},
handleStateChange: async (change) => {
if (!jmapClient.currentSession) return;
const accountId = jmapClient.accountId;
const accountChanges = change.changed?.[accountId];
if (!accountChanges) return;
const mailboxChanged = 'Mailbox' in accountChanges;
const emailChanged = 'Email' in accountChanges || 'EmailDelivery' in accountChanges;
if (!mailboxChanged && !emailChanged) return;
if (mailboxChanged) {
await get().fetchMailboxes();
}
if (emailChanged && get().currentMailboxId) {
await get().refreshEmails();
}
},
setSearchQuery: (query) => {
set({ searchQuery: query });
void get().refreshEmails();
+31
View File
@@ -59,6 +59,12 @@ interface OfflineCacheState {
remove: (ids: string[]) => Promise<void>;
clearAll: () => Promise<void>;
// Returns cached emails whose `mailboxIds` include the given mailbox,
// sorted by `receivedAt` descending. Used by selectMailbox to seed the
// list when the network is unavailable. Loads each entry from
// AsyncStorage; the index doesn't carry the mailbox set.
getEmailsInMailbox: (mailboxId: string, limit?: number) => Promise<Email[]>;
totalSize: () => number;
totalCount: () => number;
@@ -154,6 +160,31 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
persistIndex(index);
},
getEmailsInMailbox: async (mailboxId, limit = 200) => {
// 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 at = a.receivedAt ? new Date(a.receivedAt).getTime() : 0;
const bt = b.receivedAt ? new Date(b.receivedAt).getTime() : 0;
return bt - at;
});
const matches: Email[] = [];
for (const entry of sorted) {
if (matches.length >= limit) break;
try {
const raw = await AsyncStorage.getItem(entryKey(entry.id));
if (!raw) continue;
const email = JSON.parse(raw) as Email;
if (email.mailboxIds?.[mailboxId]) {
matches.push(email);
}
} catch {
// skip corrupt entries — they'll be replaced on next sync
}
}
return matches;
},
totalSize: () => {
let n = 0;
for (const e of Object.values(get().index.entries)) n += e.size;
+3 -5
View File
@@ -133,12 +133,11 @@ interface PersistedSettings {
pluginEnabled: Record<string, boolean>;
// Offline mail cache: download recent message bodies in the background so
// they can be opened without network. Days windows the lookback; the
// attachments toggle controls whether file blobs are cached too (off by
// default — bodies-only is much smaller).
// they can be opened without network. Days windows the lookback. Attachment
// caching is intentionally not implemented yet — bodies-only is much
// smaller and covers the "open recent mail offline" UX on its own.
offlineCacheEnabled: boolean;
offlineCacheDays: number;
offlineCacheIncludeAttachments: boolean;
}
const DEFAULT_PERSISTED: PersistedSettings = {
@@ -218,7 +217,6 @@ const DEFAULT_PERSISTED: PersistedSettings = {
offlineCacheEnabled: false,
offlineCacheDays: 7,
offlineCacheIncludeAttachments: false,
};
export interface SettingsState extends PersistedSettings {
+9 -3
View File
@@ -108,9 +108,15 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
if (s.installing || !s.cachedLatest?.apkAsset) return;
set({ installing: true, error: null, installProgress: { phase: 'downloading', progress: 0 } });
try {
await downloadAndInstallApk(s.cachedLatest.apkAsset, (p) => {
set({ installProgress: p });
});
await downloadAndInstallApk(
{
asset: s.cachedLatest.apkAsset,
expectedSha256: s.cachedLatest.apkSha256,
},
(p) => {
set({ installProgress: p });
},
);
set({ installing: false, installProgress: null });
} catch (err) {
set({
+66
View File
@@ -0,0 +1,66 @@
// Vitest setup. Two pieces of plumbing the unit tests need:
//
// 1. AsyncStorage: the Zustand `persist` middleware used by several stores
// reaches in during `set()` calls, which on a `node` environment crashes
// inside the RN AsyncStorage shim (no `window` global). Swap in an
// in-memory implementation.
// 2. react-native: the bundled entry point is Flow-typed and rolldown can't
// parse it. Stub the few surfaces used at module-load time so any store
// that transitively imports `react-native` (via push / client-cert
// bridges) loads cleanly. Tests that actually exercise native modules
// re-mock the relevant module locally.
import { vi } from 'vitest';
const memory = new Map<string, string>();
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (key: string) => memory.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
memory.set(key, value);
}),
removeItem: vi.fn(async (key: string) => {
memory.delete(key);
}),
clear: vi.fn(async () => {
memory.clear();
}),
getAllKeys: vi.fn(async () => Array.from(memory.keys())),
multiGet: vi.fn(async (keys: string[]) =>
keys.map((k) => [k, memory.get(k) ?? null] as [string, string | null]),
),
multiSet: vi.fn(async (pairs: [string, string][]) => {
for (const [k, v] of pairs) memory.set(k, v);
}),
multiRemove: vi.fn(async (keys: string[]) => {
for (const k of keys) memory.delete(k);
}),
},
}));
vi.mock('react-native', () => {
class NativeEventEmitter {
addListener() {
return { remove: () => undefined };
}
removeAllListeners() {}
}
return {
Platform: { OS: 'android', Version: 33, select: <T,>(spec: { default?: T; android?: T; ios?: T }) => spec.android ?? spec.default ?? spec.ios },
NativeModules: {},
NativeEventEmitter,
PermissionsAndroid: {
RESULTS: { GRANTED: 'granted', DENIED: 'denied', NEVER_ASK_AGAIN: 'never_ask_again' },
request: async () => 'granted',
},
Linking: { openURL: async () => undefined },
Appearance: { getColorScheme: () => 'dark', addChangeListener: () => ({ remove: () => undefined }) },
};
});
vi.mock('expo-secure-store', () => ({
setItemAsync: vi.fn(async () => undefined),
getItemAsync: vi.fn(async () => null),
deleteItemAsync: vi.fn(async () => undefined),
}));
+1
View File
@@ -5,5 +5,6 @@ export default defineConfig({
globals: true,
environment: 'node',
include: ['src/**/__tests__/**/*.test.ts'],
setupFiles: ['src/test-setup.ts'],
},
});