From 12911bfd460ef40fc2cfc540c41515df7fb88dd4 Mon Sep 17 00:00:00 2001 From: mo7ammedd Date: Thu, 13 Aug 2026 00:50:01 +0300 Subject: [PATCH] fix(webmail): keep redirects on deployment origin --- .../app/(routes)/mail/[folder]/page.tsx | 6 ++-- .../client/app/(routes)/mail/compose/page.tsx | 8 ++--- .../client/app/(routes)/mail/create/page.tsx | 7 +++-- apps/email/client/app/(routes)/mail/page.tsx | 4 ++- .../client/app/(routes)/settings/layout.tsx | 7 ++--- apps/email/client/app/mailto-handler.ts | 15 +++++---- apps/email/scripts/build-release.ts | 31 ++++++++++++++++++- 7 files changed, 54 insertions(+), 24 deletions(-) diff --git a/apps/email/client/app/(routes)/mail/[folder]/page.tsx b/apps/email/client/app/(routes)/mail/[folder]/page.tsx index dc495355f..dfedaf977 100644 --- a/apps/email/client/app/(routes)/mail/[folder]/page.tsx +++ b/apps/email/client/app/(routes)/mail/[folder]/page.tsx @@ -1,4 +1,4 @@ -import { useLoaderData, useNavigate } from 'react-router'; +import { redirect, useLoaderData, useNavigate } from 'react-router'; import { MailLayout } from '@/components/mail/mail'; import { useLabels } from '@/hooks/use-labels'; @@ -20,10 +20,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 redirect('/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 redirect('/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..c6d6903c5 100644 --- a/apps/email/client/app/(routes)/mail/compose/page.tsx +++ b/apps/email/client/app/(routes)/mail/compose/page.tsx @@ -7,16 +7,16 @@ import { } from '@/components/ui/dialog'; import { CreateEmail } from '@/components/create/create-email'; import { authProxy } from '@/lib/auth-proxy'; -import { useLoaderData } from 'react-router'; +import { redirect, 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 redirect('/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') ?? '')}`, + return redirect( + `/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..989b14569 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 { authProxy } from '@/lib/auth-proxy'; +import { redirect } 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 redirect('/login'); const url = new URL(request.url); const params = Object.fromEntries(url.searchParams.entries()) as { @@ -12,8 +13,8 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { body?: string; }; 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)}` : ''}`, + return redirect( + `/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..58e750df3 100644 --- a/apps/email/client/app/(routes)/mail/page.tsx +++ b/apps/email/client/app/(routes)/mail/page.tsx @@ -1,3 +1,5 @@ +import { redirect } from 'react-router'; + export function clientLoader() { - return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/inbox`); + return redirect('/mail/inbox'); } diff --git a/apps/email/client/app/(routes)/settings/layout.tsx b/apps/email/client/app/(routes)/settings/layout.tsx index bbd33d713..ba35e23bf 100644 --- a/apps/email/client/app/(routes)/settings/layout.tsx +++ b/apps/email/client/app/(routes)/settings/layout.tsx @@ -1,5 +1,5 @@ import { SettingsLayoutContent } from '@/components/ui/settings-content'; -import { Outlet } from 'react-router'; +import { Outlet, redirect } from 'react-router'; import { authProxy } from '@/lib/auth-proxy'; import type { Route } from './+types/layout'; @@ -7,10 +7,9 @@ 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 redirect('/login'); } - return null; } @@ -20,4 +19,4 @@ export default function SettingsLayout() { ); -} \ No newline at end of file +} diff --git a/apps/email/client/app/mailto-handler.ts b/apps/email/client/app/mailto-handler.ts index fe151907e..f2b35ecde 100644 --- a/apps/email/client/app/mailto-handler.ts +++ b/apps/email/client/app/mailto-handler.ts @@ -2,6 +2,7 @@ import { cleanEmailAddresses } from '../lib/email-utils'; import { trpcClient } from '@/providers/query-provider'; import type { Route } from './+types/mailto-handler'; import { authProxy } from '@/lib/auth-proxy'; +import { redirect } from 'react-router'; // Function to parse mailto URLs async function parseMailtoUrl(mailtoUrl: string) { @@ -248,37 +249,35 @@ 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 redirect('/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 redirect('/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 redirect('/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('/mail/compose', request.url); 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); if (mailtoData.cc) fallbackUrl.searchParams.append('cc', mailtoData.cc); if (mailtoData.bcc) fallbackUrl.searchParams.append('bcc', mailtoData.bcc); - return Response.redirect(fallbackUrl.toString()); + return redirect(`${fallbackUrl.pathname}${fallbackUrl.search}`); } // Redirect to compose with the draft ID - return Response.redirect( - `${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose?draftId=${draftId}`, - ); + return redirect(`/mail/compose?draftId=${draftId}`); } diff --git a/apps/email/scripts/build-release.ts b/apps/email/scripts/build-release.ts index 7b7b06b1c..8c3de2725 100644 --- a/apps/email/scripts/build-release.ts +++ b/apps/email/scripts/build-release.ts @@ -34,7 +34,7 @@ */ import { spawn } from 'node:child_process'; -import { cp, mkdir, rm, readFile, writeFile } from 'node:fs/promises'; +import { cp, mkdir, readdir, rm, readFile, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -80,6 +80,31 @@ async function writeJson(path: string, data: unknown): Promise { await writeFile(path, JSON.stringify(data, null, 2) + '\n'); } +/** + * A release build must be portable across every hostname it is deployed on. + * Vite/Wrangler can silently inline their local development defaults, so fail + * the build if the webmail origin ever leaks back into an executable bundle. + */ +async function assertPortableClientBundle(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + await assertPortableClientBundle(path); + continue; + } + if (!entry.isFile() || !entry.name.endsWith('.js')) continue; + + const source = await readFile(path, 'utf-8'); + if (source.includes('http://localhost:3000')) { + throw new Error( + `Client bundle ${path} contains the development webmail origin. ` + + 'Use a same-origin path for browser navigation instead.', + ); + } + } +} + /** * The server's `package.json` is workspace-coupled in dev (catalog refs, * postinstall reaching back to the monorepo). For the release we want a @@ -230,6 +255,10 @@ async function main() { ); } + await step('checking client bundle portability', async () => { + await assertPortableClientBundle(CLIENT_BUILD); + }); + // 3. Copy the built client into dist/client/. fs.cp recursive is // portable; no shell-out cross-platform concerns. await step('copying client/ to dist/client/', async () => {