chore: merge upstream fixes for reply threading, iOS session auth drop, calendar paging and the camera prompt (#35, #38, #39, #40)

This commit is contained in:
Linus Rath
2026-08-29 20:16:33 +02:00
9 changed files with 147 additions and 48 deletions
+1
View File
@@ -299,6 +299,7 @@
"notice_need_code": "Enter the 6-digit code from your authenticator app",
"notice_need_credentials": "Enter your email and password",
"notice_need_server": "We need a server address first",
"open_settings": "Open Settings",
"opening": "Opening {host}",
"opening_detail": "Finish signing in on the page that just opened. We’ll take it from there.",
"pairing": "Redeeming your sign-in code",
+25 -1
View File
@@ -67,8 +67,10 @@ describe('calendar operations', () => {
const call = mockRequest.mock.calls[0][0][0];
expect(call[0]).toBe('CalendarEvent/query');
// No bounds: only the calendar restriction is sent.
expect(call[1].filter).toEqual({ inCalendar: 'cal-1' });
expect(call[1].accountId).toBe('acc-1');
expect(call[1].position).toBe(0);
});
it('should send the date window as LocalDateTime after/before combined with the calendar filter', async () => {
@@ -99,6 +101,28 @@ describe('calendar operations', () => {
expect(Object.keys(call[1].filter).sort()).toEqual(['after', 'before']);
});
it('should page through a result set larger than one page', async () => {
// Regression: a full page used to be returned as the complete answer, so
// an account with more events than the page size silently lost every
// event past it - the calendar rendered empty.
const first = Array.from({ length: 1000 }, (_, i) => `a${i}`);
mockRequest
.mockResolvedValueOnce({
methodResponses: [['CalendarEvent/query', { ids: first }, '0']],
})
.mockResolvedValueOnce({
methodResponses: [['CalendarEvent/query', { ids: ['tail-1', 'tail-2'] }, '0']],
});
const result = await queryEvents(['cal-1'], '2026-03-01T00:00:00Z', '2026-03-31T23:59:59Z');
expect(result).toHaveLength(1002);
expect(result[1001]).toBe('tail-2');
expect(mockRequest).toHaveBeenCalledTimes(2);
expect(mockRequest.mock.calls[0][0][0][1].position).toBe(0);
expect(mockRequest.mock.calls[1][0][0][1].position).toBe(1000);
});
it('should OR multiple calendars and respect the target account', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
@@ -114,7 +138,7 @@ describe('calendar operations', () => {
expect(call[1].accountId).toBe('acc-shared');
});
it('should omit the filter when no calendars are given', async () => {
it('should omit the filter when no calendars are given and window is empty', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
});
+5 -3
View File
@@ -91,7 +91,7 @@ describe('JMAPClient', () => {
await client.connect('https://mail.example.com///', 'user', 'pass');
expect(global.fetch).toHaveBeenCalledWith(
'https://mail.example.com/.well-known/jmap',
'https://mail.example.com/jmap/session',
expect.any(Object),
);
});
@@ -282,7 +282,9 @@ describe('JMAPClient', () => {
expect(client.accountId).toBe('only-acc');
});
it('should throw if no accounts at all', async () => {
it('should throw on an empty (unauthenticated) session', async () => {
// Stalwart returns 200 with empty accounts for invalid credentials;
// the client surfaces that as an auth failure.
const session = {
...MOCK_SESSION,
primaryAccounts: {},
@@ -291,7 +293,7 @@ describe('JMAPClient', () => {
global.fetch = mockFetch([{ status: 200, json: session }]) as any;
await expect(client.connect('https://mail.example.com', 'user', 'pass'))
.rejects.toThrow('No account found');
.rejects.toThrow('Invalid credentials');
});
});
});
+30 -9
View File
@@ -248,6 +248,11 @@ export async function getCalendars(): Promise<Calendar[]> {
return [...own, ...shared.flat()];
}
const QUERY_PAGE_SIZE = 1000;
// Backstop against a server that keeps returning full pages; 20 pages is far
// beyond any real calendar window and keeps a bug from looping forever.
const QUERY_MAX_PAGES = 20;
/**
* Query event ids in a date window. `after`/`before` are instants (ISO, as
* from `Date.toISOString()`) or LocalDateTime strings; they're sent as
@@ -256,6 +261,8 @@ export async function getCalendars(): Promise<Calendar[]> {
* condition (the plural `inCalendars` fails the whole query with
* unsupportedFilter on Stalwart); the range and calendar conditions are
* combined with an AND operator. Without bounds every object is returned.
* The result set is paged: a window can hold more events than one page, and
* a truncated page must never pass for a complete answer.
*/
export async function queryEvents(
calendarIds: string[],
@@ -264,22 +271,36 @@ export async function queryEvents(
accountId?: string,
): Promise<string[]> {
const account = accountId || jmapClient.accountId;
const args: Record<string, unknown> = { accountId: account, limit: 1000 };
const timeZone = getUserTimeZone();
if (timeZone) args.timeZone = timeZone;
const conditions: Record<string, unknown>[] = [];
if (calendarIds.length > 0) conditions.push(buildInCalendarFilter(calendarIds));
const range: Record<string, string> = {};
if (after) range.after = toLocalDateTime(after, timeZone);
if (before) range.before = toLocalDateTime(before, timeZone);
if (Object.keys(range).length > 0) conditions.push(range);
if (conditions.length === 1) args.filter = conditions[0];
else if (conditions.length > 1) args.filter = { operator: 'AND', conditions };
const res = await jmapClient.request(
[['CalendarEvent/query', args, '0']],
USING,
);
return methodResult<{ ids: string[] }>(res).ids ?? [];
const filter = conditions.length === 1
? conditions[0]
: conditions.length > 1
? { operator: 'AND', conditions }
: undefined;
const ids: string[] = [];
for (let page = 0; page < QUERY_MAX_PAGES; page++) {
const args: Record<string, unknown> = {
accountId: account,
limit: QUERY_PAGE_SIZE,
position: ids.length,
};
if (timeZone) args.timeZone = timeZone;
if (filter) args.filter = filter;
const res = await jmapClient.request(
[['CalendarEvent/query', args, '0']],
USING,
);
const batch = methodResult<{ ids: string[] }>(res).ids ?? [];
ids.push(...batch);
if (batch.length < QUERY_PAGE_SIZE) break;
}
return ids;
}
/**
+24 -4
View File
@@ -691,10 +691,21 @@ export class JMAPClient {
// ── Session Discovery ─────────────────────────────────
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
const url = `${baseUrl}/.well-known/jmap`;
let response = await this.authenticatedFetch(url, {
headers: { Accept: 'application/json' },
});
// Stalwart's discovery endpoint /.well-known/jmap 307-redirects to
// /jmap/session. On iOS, NSURLSession drops the Authorization header when
// it auto-follows that redirect, so the app receives an unauthenticated,
// empty-accounts session. Request the session endpoint directly so the
// header stays attached; fall back to the standard well-known path for
// servers that don't serve /jmap/session (#39).
const primary = `${baseUrl}/jmap/session`;
const fallback = `${baseUrl}/.well-known/jmap`;
const fetchSessionDoc = async () => {
const r = await this.authenticatedFetch(primary, { headers: { Accept: 'application/json' } });
return r.status === 404
? this.authenticatedFetch(fallback, { headers: { Accept: 'application/json' } })
: r;
};
let response = await fetchSessionDoc();
if (response.status === 401) {
throw new AuthenticationError('Invalid credentials');
@@ -741,6 +752,15 @@ export class JMAPClient {
if (!fetchSessionDescribes(session)) {
throw new Error('Session discovery failed: response is not a JMAP session');
}
// Stalwart answers 200 with an empty session (no accounts) for missing or
// invalid credentials rather than a 401. Surface that as an auth failure
// so the user sees "Invalid credentials" instead of "No account found".
const hasAccount =
Object.keys(session.primaryAccounts ?? {}).length > 0 ||
Object.keys(session.accounts ?? {}).length > 0;
if (!hasAccount) {
throw new AuthenticationError('Invalid credentials');
}
return session;
}
+12 -6
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, Modal, Platform } from 'react-native';
import { View, Text, Pressable, StyleSheet, Modal, Platform, Linking } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
import { X } from 'lucide-react-native';
@@ -25,6 +25,8 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
// Guards against the camera firing onBarcodeScanned dozens of times for the
// same code before the modal tears down.
const handled = React.useRef(false);
// Once iOS/Android stops letting us re-prompt, the only path left is Settings.
const blocked = !!permission && !permission.granted && !permission.canAskAgain;
React.useEffect(() => {
if (visible) handled.current = false;
@@ -84,12 +86,16 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
{!permission?.granted ? (
<View style={styles.permissionWrap}>
<Text style={styles.permissionText}>
{permission && !permission.canAskAgain
? t('login.mobile.camera_disabled', 'Camera access is disabled. Enable it in Settings to scan a sign-in QR code.')
: t('login.mobile.camera_needed', 'Bulwark Mail needs camera access to scan a sign-in QR code.')}
{blocked
? t('login.mobile.camera_disabled', 'Camera access is turned off. Turn it on in Settings to scan a sign-in QR code.')
: t('login.mobile.camera_needed', 'Scanning a sign-in QR code uses the camera. The QR code is read on this device only.')}
</Text>
<Button variant="default" size="md" onPress={() => void requestPermission()}>
{t('login.mobile.camera_allow', 'Allow camera access')}
<Button
variant="default"
size="md"
onPress={() => void (blocked ? Linking.openSettings() : requestPermission())}
>
{blocked ? t('login.mobile.open_settings', 'Open Settings') : t('login.mobile.continue', 'Continue')}
</Button>
</View>
) : null}
+15 -5
View File
@@ -677,19 +677,28 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
const openDuration = useAnimDuration(240);
const closeDuration = useAnimDuration(200);
// Runs the slide-in. Kicked from both the visible effect and the Modal's
// onShow: on the very first open the modal's native view is not attached yet
// when the effect fires, so that first animation is dropped and the drawer
// stays parked off-screen. Re-running it once the modal is on screen commits
// the final offset (a no-op on every subsequent open).
const runOpen = React.useCallback(() => {
Animated.parallel([
Animated.timing(slideX, { toValue: 0, duration: openDuration, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: openDuration, useNativeDriver: true }),
]).start();
}, [slideX, overlayOpacity, openDuration]);
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideX, { toValue: 0, duration: openDuration, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: openDuration, useNativeDriver: true }),
]).start();
runOpen();
} else {
Animated.parallel([
Animated.timing(slideX, { toValue: -Dimensions.get('window').width, duration: closeDuration, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: closeDuration, useNativeDriver: true }),
]).start();
}
}, [visible, slideX, overlayOpacity, openDuration, closeDuration]);
}, [visible, runOpen, slideX, overlayOpacity, closeDuration]);
const accountEmail = username || '';
const initials = React.useMemo(
@@ -735,6 +744,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
animationType="none"
statusBarTranslucent
onRequestClose={onClose}
onShow={runOpen}
>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
@@ -73,21 +73,28 @@ export function CalendarSidebarDrawer({
const t = useLocaleStore((s) => s.t);
const [expandedId, setExpandedId] = React.useState<string | null>(null);
// Also kicked from the Modal's onShow — the first open fires this effect
// before the modal's native view exists, so that animation is dropped and
// the drawer stays parked off-screen until the second open.
const runOpen = React.useCallback(() => {
Animated.parallel([
Animated.timing(slideX, {
toValue: 0,
duration: openDuration,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(overlayOpacity, {
toValue: 1,
duration: openDuration,
useNativeDriver: true,
}),
]).start();
}, [slideX, overlayOpacity, openDuration]);
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideX, {
toValue: 0,
duration: openDuration,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(overlayOpacity, {
toValue: 1,
duration: openDuration,
useNativeDriver: true,
}),
]).start();
runOpen();
} else {
setExpandedId(null);
Animated.parallel([
@@ -104,7 +111,7 @@ export function CalendarSidebarDrawer({
}),
]).start();
}
}, [visible, slideX, overlayOpacity, openDuration, closeDuration]);
}, [visible, runOpen, slideX, overlayOpacity, closeDuration]);
const hiddenSet = React.useMemo(() => new Set(hiddenCalendarIds), [hiddenCalendarIds]);
@@ -134,6 +141,7 @@ export function CalendarSidebarDrawer({
animationType="none"
statusBarTranslucent
onRequestClose={onClose}
onShow={runOpen}
>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
@@ -102,19 +102,26 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current;
const overlay = React.useRef(new Animated.Value(0)).current;
// Also kicked from the Modal's onShow — the first open fires this effect
// before the modal's native view exists, so that animation is dropped and
// the drawer stays parked off-screen until the second open.
const runOpen = React.useCallback(() => {
Animated.parallel([
Animated.timing(slideX, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlay, { toValue: 1, duration: 240, useNativeDriver: true }),
]).start();
}, [slideX, overlay]);
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideX, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlay, { toValue: 1, duration: 240, useNativeDriver: true }),
]).start();
runOpen();
} else {
Animated.parallel([
Animated.timing(slideX, { toValue: -Dimensions.get('window').width, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlay, { toValue: 0, duration: 200, useNativeDriver: true }),
]).start();
}
}, [visible, slideX, overlay]);
}, [visible, runOpen, slideX, overlay]);
const select = (cat: ContactCategory) => {
setSelectedCategory(cat);
@@ -150,7 +157,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
);
return (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose} onShow={runOpen}>
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>