diff --git a/src/app/(auth)/register/register-client.tsx b/src/app/(auth)/register/register-client.tsx index b3d1813..4013b01 100644 --- a/src/app/(auth)/register/register-client.tsx +++ b/src/app/(auth)/register/register-client.tsx @@ -6,12 +6,14 @@ import { AlertTriangle, ArrowRight, CheckCircle2, LoaderCircle, MailPlus, XCircl import { useEffect, useState } from "react"; import { AuthShell } from "@/components/auth/auth-shell"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { TurnstileField } from "@/components/auth/turnstile"; import { getSetupStatus, + checkExistingMx, prepareSetup, submitPrimaryDomain, submitRegistration, @@ -36,7 +38,10 @@ export function RegisterClient() { const [databaseMigrated, setDatabaseMigrated] = useState(false); const [preparationComplete, setPreparationComplete] = useState(false); const [error, setError] = useState(null); - const [mxConflict, setMxConflict] = useState(false); + const [mxChecking, setMxChecking] = useState(true); + const [mxRecordsExist, setMxRecordsExist] = useState(null); + const [replaceMxRecords, setReplaceMxRecords] = useState(false); + const [mxCheckRevision, setMxCheckRevision] = useState(0); const [loading, setLoading] = useState(false); const [turnstileReset, setTurnstileReset] = useState(0); @@ -44,6 +49,38 @@ export function RegisterClient() { void runPreparation(); }, []); + const accountDomain = setupDomain ?? primaryDomain; + + useEffect(() => { + if (step !== 3 || !accountDomain) return; + + let active = true; + setMxChecking(true); + setMxRecordsExist(null); + setReplaceMxRecords(false); + setError(null); + + void checkExistingMx(accountDomain) + .then(({ ok, data }) => { + if (!active) return; + setMxChecking(false); + if (!ok || data.hasExistingMx === undefined) { + setError(typeof data.error === "string" ? data.error : "Could not check existing MX records"); + return; + } + setMxRecordsExist(data.hasExistingMx); + }) + .catch((error: unknown) => { + if (!active) return; + setMxChecking(false); + setError(error instanceof Error ? error.message : "Could not check existing MX records"); + }); + + return () => { + active = false; + }; + }, [step, accountDomain, mxCheckRevision]); + async function runPreparation() { setLoading(true); setError(null); @@ -119,8 +156,6 @@ export function RegisterClient() { setError(null); const form = new FormData(e.currentTarget); - const submitter = (e.nativeEvent as SubmitEvent).submitter as HTMLButtonElement | null; - const replaceMxRecords = submitter?.value === "true"; const domain = setupDomain ?? primaryDomain; if (!domain) { setLoading(false); @@ -139,7 +174,8 @@ export function RegisterClient() { setLoading(false); if (!ok) { if (data.code === "MX_RECORDS_CONFLICT") { - setMxConflict(true); + setMxRecordsExist(true); + setReplaceMxRecords(false); setError(null); setTurnstileReset((value) => value + 1); return; @@ -150,10 +186,9 @@ export function RegisterClient() { setTurnstileReset((value) => value + 1); return; } - router.push(data.redirect ?? "/inbox"); + window.location.assign(data.redirect ?? "/login"); } - const accountDomain = setupDomain ?? primaryDomain; const showDomainStep = hasPrimaryDomain === false && step === 2; if (hasAdminAccount === true) { @@ -324,6 +359,36 @@ export function RegisterClient() { ) : (
+ {mxChecking && ( +
+ + Checking existing MX records +
+ )} + {mxRecordsExist === false && ( +
+ + No existing MX records found +
+ )} + {mxRecordsExist === true && ( + + )}
@@ -369,27 +434,23 @@ export function RegisterClient() { {error}

)} - {mxConflict && ( -
-
- -

- Existing MX records deliver mail to another provider. Continuing deletes those records and replaces them with Cloudflare Email Routing, so the previous provider will stop receiving mail. -

-
-
+ {!mxChecking && mxRecordsExist === null && ( + )} )} diff --git a/src/app/(auth)/register/types.d.ts b/src/app/(auth)/register/types.d.ts index 1268c17..50e40f9 100644 --- a/src/app/(auth)/register/types.d.ts +++ b/src/app/(auth)/register/types.d.ts @@ -27,8 +27,12 @@ export type DomainSetupResult = { error?: string; }; +export type MxCheckResult = { + hasExistingMx?: boolean; + error?: string; +}; + export type RegisterResult = { - token?: string; redirect?: string; error?: string; code?: "MX_RECORDS_CONFLICT"; diff --git a/src/app/(auth)/register/utils.ts b/src/app/(auth)/register/utils.ts index 3be694f..a55008f 100644 --- a/src/app/(auth)/register/utils.ts +++ b/src/app/(auth)/register/utils.ts @@ -1,6 +1,7 @@ -import { persistAuthSession } from "@/lib/auth/client"; +import { clearClientSessionToken } from "@/lib/auth/client"; import type { DomainSetupResult, + MxCheckResult, RegisterResult, SetupPreparationResult, SetupStatus, @@ -34,6 +35,19 @@ export async function submitPrimaryDomain(hostname: string): Promise<{ ok: boole }; } +export async function checkExistingMx(hostname: string): Promise<{ ok: boolean; data: MxCheckResult }> { + const res = await fetch("/api/setup/domain/mx", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hostname }), + }); + + return { + ok: res.ok, + data: (await res.json()) as MxCheckResult, + }; +} + export async function submitRegistration( form: FormData, payload: { firstRun: boolean; domain: string; enableSending?: boolean; replaceMxRecords?: boolean }, @@ -61,8 +75,7 @@ export async function submitRegistration( ), }); - return { - ok: res.ok, - data: (await persistAuthSession(res)) as RegisterResult, - }; + const data = (await res.json()) as RegisterResult; + if (res.ok) clearClientSessionToken(); + return { ok: res.ok, data }; } diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 6573557..cf2925d 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -5,7 +5,7 @@ import { getDb } from "@/db"; import { mailboxes, users } from "@/db/schema"; import { hashPassword } from "@/lib/auth/password"; import { hasAdminAccount } from "@/lib/auth/setup"; -import { createSession, SESSION_COOKIE } from "@/lib/auth/session"; +import { SESSION_COOKIE } from "@/lib/auth/session"; import { newId } from "@/lib/ids"; import { firstRunRegisterSchema } from "@/lib/validators"; import { addDomainForUser } from "@/lib/domains/service"; @@ -101,15 +101,14 @@ export async function POST(request: Request) { ); } - const token = await createSession(env, userId); - const response = NextResponse.json({ ok: true, token, redirect: "/inbox" }); + const response = NextResponse.json({ ok: true, redirect: "/login" }); response.headers.set("Cache-Control", "no-store"); - response.cookies.set(SESSION_COOKIE, token, { + response.cookies.set(SESSION_COOKIE, "", { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", - maxAge: 60 * 60 * 24 * 30, + maxAge: 0, }); return response; } diff --git a/src/app/api/setup/domain/mx/route.ts b/src/app/api/setup/domain/mx/route.ts new file mode 100644 index 0000000..bf55ded --- /dev/null +++ b/src/app/api/setup/domain/mx/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { hasAdminAccount } from "@/lib/auth/setup"; +import { getEnv } from "@/lib/cloudflare"; +import { preflightDomain } from "@/lib/domains/preflight"; +import { hasConflictingMxRecords } from "@/lib/domains/mx-records"; +import { setupDomainSchema } from "@/lib/validators"; +import { readJsonBody } from "@/lib/http/request"; +import { RequestBodyTooLargeError } from "@/lib/http/errors"; + +export async function POST(request: Request) { + const env = getEnv(); + if (await hasAdminAccount(env)) { + return NextResponse.json({ error: "Initial setup is already complete" }, { status: 403 }); + } + + let body: unknown; + try { + body = await readJsonBody(request, 16 * 1024); + } catch (error) { + const status = error instanceof RequestBodyTooLargeError ? 413 : 400; + return NextResponse.json({ error: "Invalid MX check request" }, { status }); + } + + const parsed = setupDomainSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + } + + try { + const domain = await preflightDomain(env, parsed.data.hostname); + const hasExistingMx = await hasConflictingMxRecords( + env, + domain.zone.id, + domain.hostname, + ); + return NextResponse.json({ hasExistingMx }); + } catch (error) { + const message = error instanceof Error ? error.message : "MX record check failed"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/src/lib/domains/mx-records.ts b/src/lib/domains/mx-records.ts index 9d5338c..7a497fe 100644 --- a/src/lib/domains/mx-records.ts +++ b/src/lib/domains/mx-records.ts @@ -2,6 +2,10 @@ import { createDnsRecord, deleteDnsRecord, listMxRecords } from "@/lib/cloudflar import type { CfDnsRecord } from "@/lib/cloudflare-api.types"; import type { CloudflareDnsRecordCreate } from "@/lib/cloudflare-dns.types"; +function isCloudflareEmailRoutingMx(record: CfDnsRecord): boolean { + return record.content?.toLowerCase().endsWith(".mx.cloudflare.net") ?? false; +} + function toCreateInput(record: CfDnsRecord): CloudflareDnsRecordCreate { if (!record.name || !record.content || record.priority === undefined) { throw new Error("Cloudflare returned an incomplete MX record"); @@ -25,7 +29,9 @@ export async function removeMxRecords( hostname: string, deleted: CfDnsRecord[], ): Promise { - const records = await listMxRecords(env, zoneId, hostname); + const records = (await listMxRecords(env, zoneId, hostname)).filter( + (record) => !isCloudflareEmailRoutingMx(record), + ); for (const record of records) { if (!record.id) throw new Error("Cloudflare returned an MX record without an id"); @@ -34,6 +40,15 @@ export async function removeMxRecords( } } +export async function hasConflictingMxRecords( + env: CloudflareEnv, + zoneId: string, + hostname: string, +): Promise { + const records = await listMxRecords(env, zoneId, hostname); + return records.some((record) => !isCloudflareEmailRoutingMx(record)); +} + export async function restoreMxRecords( env: CloudflareEnv, zoneId: string,