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 () => {