Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/happy-trams-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ownmail/app": patch
---

Fix authenticated mailbox server rendering and isolate compose state when switching backdrop drafts.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions labs/ownmail/packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
85 changes: 85 additions & 0 deletions labs/ownmail/packages/app/scripts/smoke-ssr.mjs
Original file line number Diff line number Diff line change
@@ -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*<!doctype html>/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.')
}
25 changes: 25 additions & 0 deletions labs/ownmail/packages/app/src/-router.test.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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(
<RouterContextProvider router={router}>
{InnerWrap ? (
<InnerWrap>
<div>mailbox</div>
</InnerWrap>
) : null}
</RouterContextProvider>,
),
).not.toThrow()
})
})
2 changes: 1 addition & 1 deletion labs/ownmail/packages/app/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => <OwnmailQueryProvider client={queryClient}>{children}</OwnmailQueryProvider>,
InnerWrap: ({ children }) => <OwnmailQueryProvider client={queryClient}>{children}</OwnmailQueryProvider>,
defaultPreload: 'intent',
scrollRestoration: true,
defaultPendingMinMs: 0,
Expand Down
68 changes: 68 additions & 0 deletions labs/ownmail/packages/app/src/routes/-mail.compose.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<QueryClientProvider client={queryClient}>
<Route.options.component />
</QueryClientProvider>,
)

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(
<QueryClientProvider client={queryClient}>
<Route.options.component />
</QueryClientProvider>,
)

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**') } },
Expand Down
7 changes: 6 additions & 1 deletion labs/ownmail/packages/app/src/routes/mail.compose.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,14 @@ export const Route = createFileRoute('/mail/compose')({
: null,
}
},
component: Compose,
component: ComposeRoute,
})

function ComposeRoute() {
const requestedDraftId = Route.useSearch().draft
return <Compose key={requestedDraftId ?? '__new-compose__'} />
}

function Compose() {
const initial = Route.useLoaderData()
// Compatibility for server-rendered and preloaded route data while the query
Expand Down