From 374ad89bb0d1af3da4e39cf4cd16fb5db834b358 Mon Sep 17 00:00:00 2001 From: Bruce McLachlan Date: Tue, 1 Sep 2026 00:47:58 +0200 Subject: [PATCH] feat(mailboxes): add mailbox deletion to the settings UI The DELETE /api/mailboxes/[id] endpoint already existed but nothing in the UI called it, so a mailbox could be created but never removed. Add a "Danger zone" card to the mailbox settings page with a confirmation prompt, consistent with the existing destructive-action pattern used on the backups page, redirecting back to the mailbox list on success. Also fix a cleanup gap in the endpoint itself: creating a mailbox provisions a Cloudflare Email Routing rule via ensureMailboxDomainRouting, but deleting one only removed the database row, orphaning the routing rule so mail kept being routed to the Worker for an address that no longer existed. Add the mirrored removeMailboxDomainRouting and call it before the row is deleted, returning 502 without deleting if the Cloudflare call fails so the database and Cloudflare cannot drift apart. The rule-matching predicate used by ensureEmailRoutingRuleToWorker is extracted into isWorkerRouteForAddress and reused by the new deleteEmailRoutingRuleForAddress, so both sides match rules identically. Address selection reuses getMailboxDomainAddresses, which already excludes domains claimed by another mailbox with the same local part, so deleting a mailbox cannot remove another mailbox's routing rule. Co-Authored-By: Claude Opus 5 --- src/app/(admin)/mailboxes/[id]/page.tsx | 50 ++++++++++++++++++++++++- src/app/(admin)/mailboxes/[id]/utils.ts | 8 ++++ src/app/api/mailboxes/[id]/route.ts | 15 +++++++- src/lib/cloudflare-api.ts | 38 ++++++++++++++----- src/lib/mailboxes/domain-addresses.ts | 30 ++++++++++++++- 5 files changed, 129 insertions(+), 12 deletions(-) diff --git a/src/app/(admin)/mailboxes/[id]/page.tsx b/src/app/(admin)/mailboxes/[id]/page.tsx index 0adb8d8..8cb1f3d 100644 --- a/src/app/(admin)/mailboxes/[id]/page.tsx +++ b/src/app/(admin)/mailboxes/[id]/page.tsx @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Save, Trash2, UserPlus } from "lucide-react"; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -19,6 +19,7 @@ import { Label } from "@/components/ui/label"; import { Select } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { + deleteMailbox, fetchMailbox, fetchSharedInboxAccess, getMailboxAddress, @@ -57,6 +58,15 @@ export default function MailboxSettingsPage() { }, }); + const router = useRouter(); + const removeMailbox = useMutation({ + mutationFn: () => deleteMailbox(mailboxId), + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: ["mailboxes"] }); + router.push("/mailboxes"); + }, + }); + const sharedAccess = useQuery({ queryKey: ["mailbox", mailboxId, "access"], queryFn: () => fetchSharedInboxAccess(mailboxId), @@ -247,6 +257,44 @@ export default function MailboxSettingsPage() { )} + + + Danger zone + + Deleting this mailbox removes its Cloudflare Email Routing rule, so + new mail sent to {address || "this address"} will no longer be + accepted. Messages already received are kept in the database but + will no longer appear in any inbox. This cannot be undone. + + + + {removeMailbox.isError && ( +

+ {removeMailbox.error instanceof Error + ? removeMailbox.error.message + : "Failed to delete mailbox"} +

+ )} + +
+
+ {/* diff --git a/src/app/(admin)/mailboxes/[id]/utils.ts b/src/app/(admin)/mailboxes/[id]/utils.ts index ef524af..51dd282 100644 --- a/src/app/(admin)/mailboxes/[id]/utils.ts +++ b/src/app/(admin)/mailboxes/[id]/utils.ts @@ -60,3 +60,11 @@ export async function revokeSharedInboxAccess(id: string, userId: string): Promi const json = (await res.json()) as { error?: string }; if (!res.ok) throw new Error(json.error ?? "Failed to remove account"); } + +export async function deleteMailbox(id: string): Promise { + const res = await authFetch(`/api/mailboxes/${id}`, { method: "DELETE" }); + const json = (await res.json()) as { error?: string }; + if (!res.ok) throw new Error(json.error ?? "Failed to delete mailbox"); + + clearMailboxesCache(); +} diff --git a/src/app/api/mailboxes/[id]/route.ts b/src/app/api/mailboxes/[id]/route.ts index 7d83ab6..64860d4 100644 --- a/src/app/api/mailboxes/[id]/route.ts +++ b/src/app/api/mailboxes/[id]/route.ts @@ -5,7 +5,7 @@ import { mailboxes, users } from "@/db/schema"; import { requireUser } from "@/lib/auth/cookies"; import { getEnv } from "@/lib/cloudflare"; import { getMailboxAccessLevel } from "@/lib/mailboxes/access"; -import { ensureMailboxDomainRouting } from "@/lib/mailboxes/domain-addresses"; +import { ensureMailboxDomainRouting, removeMailboxDomainRouting } from "@/lib/mailboxes/domain-addresses"; import { updateMailboxSchema } from "@/lib/validators"; import type { MailboxRouteParams } from "./types"; import { getMailboxUpdateValues, selectMailboxForUser } from "./utils"; @@ -105,6 +105,19 @@ export async function DELETE(request: Request, { params }: MailboxRouteParams) { allowed = mailbox.userId === user.id || owner?.createdByUserId === user.id; } if (!allowed) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + try { + await removeMailboxDomainRouting(env, db, { + id: mailbox.id, + domainId: mailbox.domainId, + localPart: mailbox.localPart, + useAllDomains: mailbox.useAllDomains, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to remove Cloudflare routing rule"; + return NextResponse.json({ error: message }, { status: 502 }); + } + await db.delete(mailboxes).where(eq(mailboxes.id, id)); return NextResponse.json({ ok: true }); } diff --git a/src/lib/cloudflare-api.ts b/src/lib/cloudflare-api.ts index 4c8492f..8ce7b4e 100644 --- a/src/lib/cloudflare-api.ts +++ b/src/lib/cloudflare-api.ts @@ -184,6 +184,20 @@ export async function createEmailRoutingRuleToWorker( ); } +function isWorkerRouteForAddress( + rule: CfEmailRoutingRule, + normalizedAddress: string, + workerName: string, +): boolean { + const routesAddress = rule.matchers?.some( + (matcher) => matcher.type === "literal" && matcher.field === "to" && matcher.value?.toLowerCase() === normalizedAddress, + ); + const sendsToWorker = rule.actions?.some( + (action) => action.type === "worker" && (action.value?.length ? action.value.includes(workerName) : true), + ); + return Boolean(routesAddress && sendsToWorker); +} + export async function ensureEmailRoutingRuleToWorker( env: CloudflareEnv, zoneId: string, @@ -192,15 +206,7 @@ export async function ensureEmailRoutingRuleToWorker( const normalized = address.toLowerCase(); const workerName = getEmailWorkerName(env); const rules = await listEmailRoutingRules(env, zoneId); - const existing = rules.find((rule) => { - const routesAddress = rule.matchers?.some( - (matcher) => matcher.type === "literal" && matcher.field === "to" && matcher.value?.toLowerCase() === normalized, - ); - const sendsToWorker = rule.actions?.some( - (action) => action.type === "worker" && (action.value?.length ? action.value.includes(workerName) : true), - ); - return routesAddress && sendsToWorker; - }); + const existing = rules.find((rule) => isWorkerRouteForAddress(rule, normalized, workerName)); if (existing?.enabled) return existing; if (existing?.id) { @@ -222,3 +228,17 @@ export async function ensureEmailRoutingRuleToWorker( return createEmailRoutingRuleToWorker(env, zoneId, normalized); } + +export async function deleteEmailRoutingRuleForAddress( + env: CloudflareEnv, + zoneId: string, + address: string, +): Promise { + const normalized = address.toLowerCase(); + const workerName = getEmailWorkerName(env); + const rules = await listEmailRoutingRules(env, zoneId); + const existing = rules.find((rule) => isWorkerRouteForAddress(rule, normalized, workerName)); + if (!existing?.id) return false; + await deleteEmailRoutingRule(env, zoneId, existing.id); + return true; +} diff --git a/src/lib/mailboxes/domain-addresses.ts b/src/lib/mailboxes/domain-addresses.ts index 030a555..2b00269 100644 --- a/src/lib/mailboxes/domain-addresses.ts +++ b/src/lib/mailboxes/domain-addresses.ts @@ -1,7 +1,7 @@ import { and, eq } from "drizzle-orm"; import type { AppDatabase } from "@/db"; import { domains, mailboxes } from "@/db/schema"; -import { ensureEmailRoutingRuleToWorker } from "@/lib/cloudflare-api"; +import { deleteEmailRoutingRuleForAddress, ensureEmailRoutingRuleToWorker } from "@/lib/cloudflare-api"; import type { MailboxDomainAddressInput } from "./domain-addresses-types"; export async function getMailboxDomainAddresses( @@ -65,3 +65,31 @@ export async function ensureMailboxDomainRouting( }), ); } + +export async function removeMailboxDomainRouting( + env: CloudflareEnv, + db: AppDatabase, + mailbox: MailboxDomainAddressInput, +): Promise { + const addresses = await getMailboxDomainAddresses(db, mailbox); + if (addresses.length === 0) return; + const [primaryDomain] = await db + .select({ userId: domains.userId }) + .from(domains) + .where(eq(domains.id, mailbox.domainId)) + .limit(1); + if (!primaryDomain) return; + const availableDomains = await db + .select({ hostname: domains.hostname, zoneId: domains.zoneId }) + .from(domains) + .where(eq(domains.userId, primaryDomain.userId)); + const domainsByHostname = new Map(availableDomains.map((domain) => [domain.hostname.toLowerCase(), domain])); + + await Promise.all( + addresses.map(async (address) => { + const hostname = address.slice(address.lastIndexOf("@") + 1); + const domain = domainsByHostname.get(hostname); + if (domain) await deleteEmailRoutingRuleForAddress(env, domain.zoneId, address); + }), + ); +}