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
+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;
}