From 885ecdaa07b51e1a8703c3eaff99a70daae39228 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:10:44 +0100 Subject: [PATCH] fix: keep the server's path prefix in the CalDAV addresses shown in calendar settings --- app/api/caldav/discover/route.ts | 3 ++- lib/__tests__/caldav-discover-route.test.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/api/caldav/discover/route.ts b/app/api/caldav/discover/route.ts index 57fc62894..1b68f8b61 100644 --- a/app/api/caldav/discover/route.ts +++ b/app/api/caldav/discover/route.ts @@ -18,8 +18,9 @@ interface DiscoveryResult { const MAX_ACCOUNTS = 10; const MAX_CANDIDATES_PER_ACCOUNT = 10; +/** Appends to the server address, keeping a path prefix (Stalwart behind https://host/stalwart), like /api/webdav. */ function buildPublicUrl(serverUrl: string, path: string): string { - return new URL(path, serverUrl).toString(); + return new URL(`${serverUrl.replace(/\/+$/, '')}${path}`).toString(); } async function probeCalendarHome( diff --git a/lib/__tests__/caldav-discover-route.test.ts b/lib/__tests__/caldav-discover-route.test.ts index dfb9e5a93..52e9aff70 100644 --- a/lib/__tests__/caldav-discover-route.test.ts +++ b/lib/__tests__/caldav-discover-route.test.ts @@ -86,6 +86,15 @@ describe('POST /api/caldav/discover', () => { expect((await res.json()).accounts.A).toEqual({ url: target('c2'), resolvedAccount: 'c2' }); }); + it('keeps the path prefix of a server mounted below the root', async () => { + mockCreds.mockResolvedValue({ ...CREDS, serverUrl: 'https://host.example.com/stalwart/' }); + fetchSpy.mockResolvedValue({ status: 207, headers: new Headers() }); + const data = await read(await POST(makeReq({ accounts: [{ key: 'A', candidates: ['c1'] }] }))).json(); + expect(data.wellKnownUrl).toBe('https://host.example.com/stalwart/.well-known/caldav'); + expect(data.accounts.A.url).toBe('https://host.example.com/stalwart/dav/cal/c1'); + expect(fetchSpy).toHaveBeenCalledWith('https://host.example.com/stalwart/dav/cal/c1', expect.anything()); + }); + it('de-duplicates and trims candidates before probing', async () => { fetchSpy.mockResolvedValue({ status: 404, headers: new Headers() }); await POST(makeReq({ accounts: [{ key: 'A', candidates: [' c1 ', 'c1', '', 'c1'] }] }));