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 <noreply@anthropic.com>
This commit is contained in:
Bruce McLachlanandClaude Opus 5 committed 2026-09-01 00:48:30 +02:00
1 parent d600403aaa
commit 374ad89bb0
5 files changed
+129 -12

No files matched your search

+49 -1
View File
@@ -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() {
</CardContent>
</Card>
)}
<Card className="rounded-3xl border-0 bg-white p-6">
<CardHeader className="py-0">
<CardTitle className="text-red-700">Danger zone</CardTitle>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent className="pt-5">
{removeMailbox.isError && (
<p className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{removeMailbox.error instanceof Error
? removeMailbox.error.message
: "Failed to delete mailbox"}
</p>
)}
<Button
type="button"
variant="destructive"
disabled={!mailbox.data || removeMailbox.isPending}
onClick={() => {
if (
!window.confirm(
`Delete ${address}? This removes its email routing rule and cannot be undone.`,
)
)
return;
removeMailbox.mutate();
}}
>
<Trash2 className="h-4 w-4" />
{removeMailbox.isPending ? "Deleting..." : "Delete mailbox"}
</Button>
</CardContent>
</Card>
{/*
<Card className="rounded-3xl border-0 bg-white p-6">
<CardHeader className="py-0">
+8
View File
@@ -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<void> {
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();
}
+14 -1
View File
@@ -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 });
}
+29 -9
View File
@@ -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<boolean> {
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;
}
+29 -1
View File
@@ -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<void> {
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);
}),
);
}