Compare commits

...
3 Commits
9 changed files with 61 additions and 27 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.17
0.1.18
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.17",
"version": "0.1.18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.17",
"version": "0.1.18",
"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.18",
"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'));
}
+18 -9
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}`;
@@ -161,17 +164,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 +186,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 {
+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) => {
+1 -3
View File
@@ -7,9 +7,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 {
+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;
+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) {