mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-06 00:07:28 +00:00
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:
@@ -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']],
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user