From 40d6beaafa651d9d9b7d1d1ff02f1ceba8a207c6 Mon Sep 17 00:00:00 2001 From: Santhi Prakash Date: Tue, 4 Aug 2026 03:01:00 +0000 Subject: [PATCH] fix(email): resolve webmail logout redirect to /mail/undefined/login - Problem: self-hosted builds omit VITE_PUBLIC_APP_URL, so clientLoader redirects stringified to the relative URL undefined/login. - Fix: add app-url helper (mirrors backend-url) and route all SPA redirects through absoluteAppUrl at runtime. - Verification: bun test apps/email/client/lib/app-url.test.ts Co-Authored-By: Paperclip --- .../app/(routes)/mail/[folder]/page.tsx | 5 ++- .../client/app/(routes)/mail/compose/page.tsx | 7 +++- .../client/app/(routes)/mail/create/page.tsx | 7 +++- apps/email/client/app/(routes)/mail/page.tsx | 4 +- .../client/app/(routes)/settings/layout.tsx | 3 +- apps/email/client/app/mailto-handler.ts | 13 +++---- apps/email/client/lib/app-url.test.ts | 12 ++++++ apps/email/client/lib/app-url.ts | 38 +++++++++++++++++++ 8 files changed, 74 insertions(+), 15 deletions(-) create mode 100644 apps/email/client/lib/app-url.test.ts create mode 100644 apps/email/client/lib/app-url.ts diff --git a/apps/email/client/app/(routes)/mail/[folder]/page.tsx b/apps/email/client/app/(routes)/mail/[folder]/page.tsx index dc495355f..492a37707 100644 --- a/apps/email/client/app/(routes)/mail/[folder]/page.tsx +++ b/apps/email/client/app/(routes)/mail/[folder]/page.tsx @@ -2,6 +2,7 @@ import { useLoaderData, useNavigate } from 'react-router'; import { MailLayout } from '@/components/mail/mail'; import { useLabels } from '@/hooks/use-labels'; +import { absoluteAppUrl } from '@/lib/app-url'; import { authProxy } from '@/lib/auth-proxy'; import { useEffect, useState } from 'react'; import type { Route } from './+types/page'; @@ -20,10 +21,10 @@ const ALLOWED_FOLDERS = new Set([ ]); export async function clientLoader({ params, request }: Route.ClientLoaderArgs) { - if (!params.folder) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/inbox`); + if (!params.folder) return Response.redirect(absoluteAppUrl('/mail/inbox')); const session = await authProxy.api.getSession({ headers: request.headers }); - if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`); + if (!session) return Response.redirect(absoluteAppUrl('/login')); return { folder: params.folder, diff --git a/apps/email/client/app/(routes)/mail/compose/page.tsx b/apps/email/client/app/(routes)/mail/compose/page.tsx index 1f873d1e3..f31736806 100644 --- a/apps/email/client/app/(routes)/mail/compose/page.tsx +++ b/apps/email/client/app/(routes)/mail/compose/page.tsx @@ -6,17 +6,20 @@ import { DialogTrigger, } from '@/components/ui/dialog'; import { CreateEmail } from '@/components/create/create-email'; +import { absoluteAppUrl } from '@/lib/app-url'; import { authProxy } from '@/lib/auth-proxy'; import { useLoaderData } from 'react-router'; import type { Route } from './+types/page'; export async function clientLoader({ request }: Route.ClientLoaderArgs) { const session = await authProxy.api.getSession({ headers: request.headers }); - if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`); + if (!session) return Response.redirect(absoluteAppUrl('/login')); const url = new URL(request.url); if (url.searchParams.get('to')?.startsWith('mailto:')) { return Response.redirect( - `${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose/handle-mailto?mailto=${encodeURIComponent(url.searchParams.get('to') ?? '')}`, + absoluteAppUrl( + `/mail/compose/handle-mailto?mailto=${encodeURIComponent(url.searchParams.get('to') ?? '')}`, + ), ); } diff --git a/apps/email/client/app/(routes)/mail/create/page.tsx b/apps/email/client/app/(routes)/mail/create/page.tsx index 57ac8005c..6265542fc 100644 --- a/apps/email/client/app/(routes)/mail/create/page.tsx +++ b/apps/email/client/app/(routes)/mail/create/page.tsx @@ -1,9 +1,10 @@ +import { absoluteAppUrl } from '@/lib/app-url'; import { authProxy } from '@/lib/auth-proxy'; import type { Route } from './+types/page'; export async function clientLoader({ request }: Route.ClientLoaderArgs) { const session = await authProxy.api.getSession({ headers: request.headers }); - if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`); + if (!session) return Response.redirect(absoluteAppUrl('/login')); const url = new URL(request.url); const params = Object.fromEntries(url.searchParams.entries()) as { @@ -13,7 +14,9 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { }; const toParam = params.to || 'someone@someone.com'; return Response.redirect( - `${import.meta.env.VITE_PUBLIC_APP_URL}/mail/inbox?isComposeOpen=true&to=${encodeURIComponent(toParam)}${params.subject ? `&subject=${encodeURIComponent(params.subject)}` : ''}`, + absoluteAppUrl( + `/mail/inbox?isComposeOpen=true&to=${encodeURIComponent(toParam)}${params.subject ? `&subject=${encodeURIComponent(params.subject)}` : ''}`, + ), ); } diff --git a/apps/email/client/app/(routes)/mail/page.tsx b/apps/email/client/app/(routes)/mail/page.tsx index aeb5f05ea..4529b9f31 100644 --- a/apps/email/client/app/(routes)/mail/page.tsx +++ b/apps/email/client/app/(routes)/mail/page.tsx @@ -1,3 +1,5 @@ +import { absoluteAppUrl } from '@/lib/app-url'; + export function clientLoader() { - return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/inbox`); + return Response.redirect(absoluteAppUrl('/mail/inbox')); } diff --git a/apps/email/client/app/(routes)/settings/layout.tsx b/apps/email/client/app/(routes)/settings/layout.tsx index bbd33d713..2c347a543 100644 --- a/apps/email/client/app/(routes)/settings/layout.tsx +++ b/apps/email/client/app/(routes)/settings/layout.tsx @@ -1,4 +1,5 @@ import { SettingsLayoutContent } from '@/components/ui/settings-content'; +import { absoluteAppUrl } from '@/lib/app-url'; import { Outlet } from 'react-router'; import { authProxy } from '@/lib/auth-proxy'; import type { Route } from './+types/layout'; @@ -7,7 +8,7 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { const session = await authProxy.api.getSession({ headers: request.headers }); if (!session) { - return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`); + return Response.redirect(absoluteAppUrl('/login')); } diff --git a/apps/email/client/app/mailto-handler.ts b/apps/email/client/app/mailto-handler.ts index fe151907e..aa8a2d572 100644 --- a/apps/email/client/app/mailto-handler.ts +++ b/apps/email/client/app/mailto-handler.ts @@ -1,4 +1,5 @@ import { cleanEmailAddresses } from '../lib/email-utils'; +import { absoluteAppUrl } from '@/lib/app-url'; import { trpcClient } from '@/providers/query-provider'; import type { Route } from './+types/mailto-handler'; import { authProxy } from '@/lib/auth-proxy'; @@ -248,27 +249,27 @@ async function createDraftFromMailto(mailtoData: { export async function clientLoader({ request }: Route.ClientLoaderArgs) { const session = await authProxy.api.getSession({ headers: request.headers }); - if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`); + if (!session) return Response.redirect(absoluteAppUrl('/login')); const url = new URL(request.url); // Get the mailto parameter from the URL const mailto = url.searchParams.get('mailto'); - if (!mailto) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose`); + if (!mailto) return Response.redirect(absoluteAppUrl('/mail/compose')); // Parse the mailto URL const mailtoData = await parseMailtoUrl(mailto); // If parsing failed, redirect to empty compose - if (!mailtoData) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose`); + if (!mailtoData) return Response.redirect(absoluteAppUrl('/mail/compose')); // Create a draft from the mailto data const draftId = await createDraftFromMailto(mailtoData); // If draft creation failed, redirect to empty compose with the parsed data as a fallback if (!draftId) { - const fallbackUrl = new URL(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose`); + const fallbackUrl = new URL(absoluteAppUrl('/mail/compose')); if (mailtoData.to) fallbackUrl.searchParams.append('to', mailtoData.to); if (mailtoData.subject) fallbackUrl.searchParams.append('subject', mailtoData.subject); if (mailtoData.body) fallbackUrl.searchParams.append('body', mailtoData.body); @@ -278,7 +279,5 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { } // Redirect to compose with the draft ID - return Response.redirect( - `${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose?draftId=${draftId}`, - ); + return Response.redirect(absoluteAppUrl(`/mail/compose?draftId=${draftId}`)); } diff --git a/apps/email/client/lib/app-url.test.ts b/apps/email/client/lib/app-url.test.ts new file mode 100644 index 000000000..137592106 --- /dev/null +++ b/apps/email/client/lib/app-url.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'bun:test'; +import { absoluteAppUrl } from './app-url'; + +describe('absoluteAppUrl', () => { + it('falls back to a root-relative path when no origin is available', () => { + // Self-hosted builds omit VITE_PUBLIC_APP_URL; in Node/bun tests window is + // also absent. The old `${import.meta.env.VITE_PUBLIC_APP_URL}/login` + // pattern stringified to "undefined/login" and broke logout redirects. + expect(absoluteAppUrl('/login')).toBe('/login'); + expect(absoluteAppUrl('/login')).not.toContain('undefined'); + }); +}); diff --git a/apps/email/client/lib/app-url.ts b/apps/email/client/lib/app-url.ts new file mode 100644 index 000000000..f12aadee7 --- /dev/null +++ b/apps/email/client/lib/app-url.ts @@ -0,0 +1,38 @@ +/** + * Single source of truth for the SPA's public origin. + * + * Zero is always served same-origin — the Hono server hosts both the SPA and + * the API on the same port. At runtime the app URL is whatever the browser + * loaded the page from (`window.location.origin`). No env, no build-time + * baking, one build deploys anywhere. + * + * Dev still needs a fallback: when the Vite dev server runs the SPA on port + * 3000, `window.location.origin` is correct for the SPA. The optional + * `VITE_PUBLIC_APP_URL` env var is consulted only off-browser (SSR / + * module-load on Node) where `window` doesn't exist. + * + * Without a runtime origin, `${undefined}/login` was stringified to the + * relative URL `undefined/login` — e.g. after logout from `/mail/inbox` the + * browser resolved it to `/mail/undefined/login`. + */ + +function isBrowser(): boolean { + return typeof window !== 'undefined' && !!window.location?.origin; +} + +export function getAppUrl(): string { + if (isBrowser()) return window.location.origin; + const fromEnv = + typeof import.meta !== 'undefined' + ? (import.meta.env?.VITE_PUBLIC_APP_URL as string | undefined) + : undefined; + if (fromEnv && fromEnv !== 'undefined') return fromEnv; + return ''; +} + +/** Build an absolute redirect target; falls back to a root-relative path. */ +export function absoluteAppUrl(path: string): string { + const origin = getAppUrl(); + const normalized = path.startsWith('/') ? path : `/${path}`; + return origin ? `${origin}${normalized}` : normalized; +}