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"} + + )} + { + if ( + !window.confirm( + `Delete ${address}? This removes its email routing rule and cannot be undone.`, + ) + ) + return; + removeMailbox.mutate(); + }} + > + + {removeMailbox.isPending ? "Deleting..." : "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); + }), + ); +}
+ {removeMailbox.error instanceof Error + ? removeMailbox.error.message + : "Failed to delete mailbox"} +