diff --git a/.changeset/happy-trams-cheat.md b/.changeset/happy-trams-cheat.md
new file mode 100644
index 00000000..ae4d5959
--- /dev/null
+++ b/.changeset/happy-trams-cheat.md
@@ -0,0 +1,5 @@
+---
+"@ownmail/app": patch
+---
+
+Fix authenticated mailbox server rendering and isolate compose state when switching backdrop drafts.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 17c2f7e6..7426e439 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -76,6 +76,7 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm turbo build typecheck test --filter=...{labs/ownmail/packages/*}
+ - run: pnpm --filter @ownmail/app smoke:ssr
- run: pnpm smoke:ownmail-release
shared:
diff --git a/labs/ownmail/packages/app/package.json b/labs/ownmail/packages/app/package.json
index 1756aa0d..60de21d6 100644
--- a/labs/ownmail/packages/app/package.json
+++ b/labs/ownmail/packages/app/package.json
@@ -60,6 +60,7 @@
"build:cloudflare": "vite build",
"build:node": "vite build -c vite.config.local.ts --outDir dist-vercel",
"build:vercel": "vite build -c vite.config.vercel.ts",
+ "smoke:ssr": "node scripts/smoke-ssr.mjs",
"test": "vitest run --coverage",
"coverage": "vitest run --coverage",
"pretypecheck": "pnpm --filter @nylas-labs/cli-kit build",
diff --git a/labs/ownmail/packages/app/scripts/smoke-ssr.mjs b/labs/ownmail/packages/app/scripts/smoke-ssr.mjs
new file mode 100644
index 00000000..dca527fd
--- /dev/null
+++ b/labs/ownmail/packages/app/scripts/smoke-ssr.mjs
@@ -0,0 +1,85 @@
+import { spawn } from 'node:child_process'
+import { createServer } from 'node:net'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const appRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
+const host = '127.0.0.1'
+const port = await availablePort(host)
+const origin = `http://${host}:${port}`
+const output = []
+const server = spawn(process.execPath, ['scripts/serve-node.mjs'], {
+ cwd: appRoot,
+ env: {
+ ...process.env,
+ APP_NAME: 'ownmail-ssr-smoke',
+ HOST: host,
+ INBOX_EMAIL: 'ada@ownmail.test',
+ NODE_ENV: 'development',
+ NYLAS_API_KEY: 'smoke-test-key',
+ NYLAS_CLIENT_ID: 'smoke-test-client',
+ NYLAS_REGION: 'us',
+ OWNMAIL_DEV_MOCKS: '1',
+ PORT: String(port),
+ SESSION_SECRET: 'ownmail-ssr-smoke-session-secret',
+ TEMPLATE_VERSION: '0.0.0-smoke',
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+})
+
+server.stdout.on('data', (chunk) => output.push(chunk.toString()))
+server.stderr.on('data', (chunk) => output.push(chunk.toString()))
+
+try {
+ await waitForServer(`${origin}/healthz`)
+ const response = await fetch(`${origin}/mail/f/inbox`, {
+ headers: { Cookie: 'ownmail_session=authenticated' },
+ redirect: 'manual',
+ signal: AbortSignal.timeout(10_000),
+ })
+ const body = await response.text()
+ if (response.status !== 200 || !/^\s*/i.test(body)) {
+ throw new Error(
+ `Authenticated mailbox SSR returned ${response.status} ${response.statusText || ''} with ${body.length} bytes.`,
+ )
+ }
+ console.log('OwnMail authenticated mailbox SSR smoke test passed.')
+} catch (error) {
+ const logs = output.join('').trim()
+ if (logs) process.stderr.write(`${logs}\n`)
+ throw error
+} finally {
+ server.kill('SIGTERM')
+ await Promise.race([
+ new Promise((resolve) => server.once('exit', resolve)),
+ new Promise((resolve) => setTimeout(resolve, 5_000)),
+ ])
+ if (server.exitCode === null) server.kill('SIGKILL')
+}
+
+async function availablePort(hostname) {
+ const probe = createServer()
+ await new Promise((resolve, reject) => {
+ probe.once('error', reject)
+ probe.listen(0, hostname, resolve)
+ })
+ const address = probe.address()
+ if (!address || typeof address === 'string') throw new Error('Could not allocate an SSR smoke-test port.')
+ await new Promise((resolve, reject) => probe.close((error) => (error ? reject(error) : resolve())))
+ return address.port
+}
+
+async function waitForServer(url) {
+ const deadline = Date.now() + 30_000
+ while (Date.now() < deadline) {
+ if (server.exitCode !== null) throw new Error(`OwnMail SSR server exited with code ${server.exitCode}.`)
+ try {
+ const response = await fetch(url, { signal: AbortSignal.timeout(1_000) })
+ if (response.ok) return
+ } catch {
+ // The built server may still be starting; retry until the bounded deadline.
+ }
+ await new Promise((resolve) => setTimeout(resolve, 100))
+ }
+ throw new Error('Timed out waiting for the OwnMail SSR server.')
+}
diff --git a/labs/ownmail/packages/app/src/-router.test.tsx b/labs/ownmail/packages/app/src/-router.test.tsx
index f4c662f5..3a63433c 100644
--- a/labs/ownmail/packages/app/src/-router.test.tsx
+++ b/labs/ownmail/packages/app/src/-router.test.tsx
@@ -1,3 +1,5 @@
+import { createMemoryHistory, RouterContextProvider } from '@tanstack/react-router'
+import { renderToString } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { getRouter } from './router.js'
@@ -12,4 +14,27 @@ describe('getRouter', () => {
// The route tree must be attached or every route 404s.
expect(router.routeTree).toBeDefined()
})
+
+ it('renders the query provider inside router context during SSR', () => {
+ const router = getRouter()
+ router.update({ ...router.options, history: createMemoryHistory({ initialEntries: ['/'] }) })
+ const InnerWrap = router.options.InnerWrap
+
+ // OwnmailQueryProvider calls useRouterState. TanStack Router's outer Wrap is
+ // above RouterContextProvider, so putting it there crashes SSR while reading
+ // router.stores. Keep this provider on the hook-safe side of the boundary.
+ expect(router.options.Wrap).toBeUndefined()
+ expect(InnerWrap).toBeTypeOf('function')
+ expect(() =>
+ renderToString(
+
+ {InnerWrap ? (
+
+ mailbox
+
+ ) : null}
+ ,
+ ),
+ ).not.toThrow()
+ })
})
diff --git a/labs/ownmail/packages/app/src/router.tsx b/labs/ownmail/packages/app/src/router.tsx
index 5ac9a34c..9c91682c 100644
--- a/labs/ownmail/packages/app/src/router.tsx
+++ b/labs/ownmail/packages/app/src/router.tsx
@@ -8,7 +8,7 @@ export function getRouter() {
routeTree,
context: { queryClient },
/* v8 ignore next -- the wrapper executes only inside TanStack Start's router runtime -- @preserve */
- Wrap: ({ children }) => {children},
+ InnerWrap: ({ children }) => {children},
defaultPreload: 'intent',
scrollRestoration: true,
defaultPendingMinMs: 0,
diff --git a/labs/ownmail/packages/app/src/routes/-mail.compose.test.tsx b/labs/ownmail/packages/app/src/routes/-mail.compose.test.tsx
index a8adbe11..6d2fd1d2 100644
--- a/labs/ownmail/packages/app/src/routes/-mail.compose.test.tsx
+++ b/labs/ownmail/packages/app/src/routes/-mail.compose.test.tsx
@@ -352,6 +352,74 @@ describe('mail.compose composer prefill', () => {
)
})
+ it('resets every draft-owned field when backdrop navigation selects another draft', async () => {
+ let search = { draft: 'draft-one' }
+ let loaderData = {
+ draft: {
+ id: 'draft-one',
+ to: [{ email: 'first@example.com' }],
+ subject: 'First subject',
+ body: 'First body',
+ },
+ folders: [],
+ threads: [],
+ selected: null,
+ folderId: 'drafts',
+ reply: null,
+ }
+ Route.useLoaderData = vi.fn(() => loaderData)
+ Route.useSearch = vi.fn(() => search)
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ const view = render(
+
+
+ ,
+ )
+
+ fireEvent.change(screen.getByLabelText('To'), { target: { value: 'edited-first@example.com' } })
+ fireEvent.change(screen.getByLabelText('Subject'), { target: { value: 'Edited first subject' } })
+ fireEvent.change(screen.getByPlaceholderText('Write your message...'), {
+ target: { value: 'Edited first body' },
+ })
+
+ search = { draft: 'draft-two' }
+ loaderData = {
+ ...loaderData,
+ draft: {
+ id: 'draft-two',
+ to: [{ email: 'second@example.com' }],
+ subject: 'Second subject',
+ body: 'Second body',
+ },
+ }
+ view.rerender(
+
+
+ ,
+ )
+
+ await waitFor(() =>
+ expect((screen.getByPlaceholderText('Write your message...') as HTMLTextAreaElement).value).toBe(
+ 'Second body',
+ ),
+ )
+ expect((screen.getByLabelText('To') as HTMLInputElement).value).toBe('second@example.com')
+ expect((screen.getByLabelText('Subject') as HTMLInputElement).value).toBe('Second subject')
+
+ saveDraft.mockResolvedValue({ draftId: 'draft-two' })
+ fireEvent.click(screen.getByRole('button', { name: /Send/ }))
+ await waitFor(() =>
+ expect(saveDraft).toHaveBeenCalledWith({
+ data: {
+ draftId: 'draft-two',
+ to: 'second@example.com',
+ subject: 'Second subject',
+ body: markdownToDraftBody('Second body'),
+ },
+ }),
+ )
+ })
+
it('converts an enveloped markdown draft back to its source before prefilling the editor', () => {
renderCompose({
loader: { draft: { id: 'd0', body: markdownToDraftBody('**Draft body**') } },
diff --git a/labs/ownmail/packages/app/src/routes/mail.compose.tsx b/labs/ownmail/packages/app/src/routes/mail.compose.tsx
index 24f88ff0..083996a2 100644
--- a/labs/ownmail/packages/app/src/routes/mail.compose.tsx
+++ b/labs/ownmail/packages/app/src/routes/mail.compose.tsx
@@ -165,9 +165,14 @@ export const Route = createFileRoute('/mail/compose')({
: null,
}
},
- component: Compose,
+ component: ComposeRoute,
})
+function ComposeRoute() {
+ const requestedDraftId = Route.useSearch().draft
+ return
+}
+
function Compose() {
const initial = Route.useLoaderData()
// Compatibility for server-rendered and preloaded route data while the query