diff --git a/apps/backend/src/app/api/latest/internal/emails/managed-onboarding/delete/route.tsx b/apps/backend/src/app/api/latest/internal/emails/managed-onboarding/delete/route.tsx new file mode 100644 index 0000000000..e8d5170aa3 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/emails/managed-onboarding/delete/route.tsx @@ -0,0 +1,40 @@ +import { deleteManagedEmailProvider } from "@/lib/managed-email-onboarding"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + body: yupObject({ + resend_domain_id: yupString().defined(), + }).defined(), + method: yupString().oneOf(["POST"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + status: yupString().oneOf(["deleted"]).defined(), + }).defined(), + }), + handler: async ({ auth, body }) => { + const result = await deleteManagedEmailProvider({ + tenancy: auth.tenancy, + resendDomainId: body.resend_domain_id, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + status: result.status, + }, + }; + }, +}); diff --git a/apps/backend/src/lib/managed-email-domains.tsx b/apps/backend/src/lib/managed-email-domains.tsx index c31c3960c6..ee0cd191dc 100644 --- a/apps/backend/src/lib/managed-email-domains.tsx +++ b/apps/backend/src/lib/managed-email-domains.tsx @@ -224,3 +224,28 @@ export async function listManagedEmailDomainsForTenancy(tenancyId: string): Prom `); return rows.map(mapRow); } + +export async function deleteManagedEmailDomainById(id: string): Promise { + const rows = await globalPrismaClient.$queryRaw(Prisma.sql` + DELETE FROM "ManagedEmailDomain" + WHERE "id" = ${id} + RETURNING * + `); + if (rows.length === 0) { + return null; + } + return mapRow(rows[0]!); +} + +export async function countManagedEmailDomainsBySubdomainExcludingId(options: { + subdomain: string, + excludeId: string, +}): Promise { + const rows = await globalPrismaClient.$queryRaw<{ count: bigint }[]>(Prisma.sql` + SELECT COUNT(*)::bigint AS count + FROM "ManagedEmailDomain" + WHERE "subdomain" = ${options.subdomain} + AND "id" <> ${options.excludeId} + `); + return Number(rows[0]?.count ?? 0n); +} diff --git a/apps/backend/src/lib/managed-email-onboarding.tsx b/apps/backend/src/lib/managed-email-onboarding.tsx index 11eb3f051a..405d8b0030 100644 --- a/apps/backend/src/lib/managed-email-onboarding.tsx +++ b/apps/backend/src/lib/managed-email-onboarding.tsx @@ -8,6 +8,8 @@ import { getManagedEmailDomainByTenancyAndSubdomain, listManagedEmailDomainsForTenancy, markManagedEmailDomainApplied, + countManagedEmailDomainsBySubdomainExcludingId, + deleteManagedEmailDomainById, updateManagedEmailDomainWebhookStatus, } from "@/lib/managed-email-domains"; import { Tenancy } from "@/lib/tenancies"; @@ -225,6 +227,26 @@ async function createDnsimpleZone(subdomain: string): Promise { }; } +async function deleteDnsimpleZoneByName(zoneName: string): Promise<{ status: "deleted" | "not_found" }> { + const dnsimpleBaseUrl = getDnsimpleBaseUrl(); + const dnsimpleAccountId = getDnsimpleAccountId(); + const response = await fetch(`${dnsimpleBaseUrl}/${encodeURIComponent(dnsimpleAccountId)}/zones/${encodeURIComponent(zoneName)}`, { + method: "DELETE", + headers: getDnsimpleHeaders(), + }); + if (response.status === 404) { + return { status: "not_found" }; + } + if (!response.ok) { + const responseBody = await response.text(); + throw new StatusError( + 502, + `DNSimple returned ${response.status} when deleting managed email zone ${zoneName}: ${responseBody.slice(0, 500)}`, + ); + } + return { status: "deleted" }; +} + async function createOrReuseDnsimpleZone(subdomain: string): Promise { const existingZones = await listDnsimpleZones(subdomain); if (existingZones.length > 1) { @@ -650,6 +672,67 @@ export async function applyManagedEmailProvider(options: { return { status: "applied" }; } +function isManagedEmailDomainInUseForTenancy(options: { + tenancy: Tenancy, + subdomain: string, + senderLocalPart: string, +}): boolean { + const emailServer = options.tenancy.config.emails.server; + return emailServer.provider === "managed" + && emailServer.managedSubdomain === options.subdomain + && emailServer.managedSenderLocalPart === options.senderLocalPart; +} + +export async function deleteManagedEmailProvider(options: { + tenancy: Tenancy, + resendDomainId: string, +}): Promise<{ status: "deleted" }> { + const domain = await getManagedEmailDomainByResendDomainId(options.resendDomainId); + if (!domain || domain.tenancyId !== options.tenancy.id) { + throw new StatusError(404, "Managed domain not found for this project/branch"); + } + if (isManagedEmailDomainInUseForTenancy({ + tenancy: options.tenancy, + subdomain: domain.subdomain, + senderLocalPart: domain.senderLocalPart, + })) { + throw new StatusError(409, "Cannot delete a managed domain that is currently in use for sending email"); + } + + // External cleanup must succeed before we drop the DB row; otherwise a failure here + // would leak provider-side resources with no record left to retry against. + if (!shouldUseMockManagedEmailOnboarding()) { + const resendApiKey = getEnvVariable("STACK_RESEND_API_KEY"); + const resendResponse = await fetch(`https://api.resend.com/domains/${encodeURIComponent(domain.resendDomainId)}`, { + method: "DELETE", + headers: { + "Authorization": `Bearer ${resendApiKey}`, + }, + }); + if (!resendResponse.ok && resendResponse.status !== 404) { + const responseBody = await resendResponse.text(); + throw new StatusError( + 502, + `Upstream email provider returned ${resendResponse.status} when deleting managed domain ${domain.resendDomainId}: ${responseBody.slice(0, 500)}`, + ); + } + + // createOrReuseDnsimpleZone lets multiple ManagedEmailDomain rows share a zone (when + // two tenancies pick the same subdomain). Only delete the zone if this row is the + // last one referencing it. + const remaining = await countManagedEmailDomainsBySubdomainExcludingId({ + subdomain: domain.subdomain, + excludeId: domain.id, + }); + if (remaining === 0) { + await deleteDnsimpleZoneByName(domain.subdomain); + } + } + + await deleteManagedEmailDomainById(domain.id); + return { status: "deleted" }; +} + export async function processResendDomainWebhookEvent(options: { domainId: string, providerStatusRaw: string, @@ -691,4 +774,3 @@ async function saveManagedEmailProviderConfig(options: { }, }); } - diff --git a/apps/dashboard/public/assets/cloudflare-import-dns.png b/apps/dashboard/public/assets/cloudflare-import-dns.png new file mode 100644 index 0000000000..daa76f62db Binary files /dev/null and b/apps/dashboard/public/assets/cloudflare-import-dns.png differ diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-settings/domain-settings.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-settings/domain-settings.tsx index cafd59cf82..e6af5aea89 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-settings/domain-settings.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-settings/domain-settings.tsx @@ -19,19 +19,22 @@ import { CheckCircle, Cloud, CopySimple, + DownloadSimple, Envelope, GlobeSimple, HardDrives, type Icon as PhosphorIcon, + Info, PaperPlaneTilt, Plus, ShieldCheck, Spinner, + Trash, WarningDiamond, } from "@phosphor-icons/react"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; -import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; -import { Dialog, DialogContent, DialogTitle, Label, Typography, useToast } from "@/components/ui"; +import { runAsynchronously, runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { ActionDialog, Dialog, DialogContent, DialogTitle, Label, Popover, PopoverContent, PopoverTrigger, Typography, useToast } from "@/components/ui"; import { useCallback, useEffect, useMemo, useState } from "react"; import * as yup from "yup"; import Image from "next/image"; @@ -105,6 +108,18 @@ function getFormValuesFromConfig(config: CompleteConfig["emails"]["server"], pro }; } +function CloudflareIcon({ className }: { className?: string }) { + return ( + // eslint-disable-next-line @next/next/no-img-element + + ); +} + function ResendIcon({ className }: { className?: string }) { return ( <> @@ -216,6 +231,58 @@ const senderLocalPartSchema = yup .defined("Sender local part is required") .test("non-empty", "Sender local part is required", (value) => value.trim().length > 0); +async function lookupAuthoritativeNameServers(name: string): Promise { + // Best-effort DoH lookup powering an optional UX hint. Swallow network/parse errors + // silently — ad-blockers, corp firewalls, and offline use are all expected here and + // shouldn't alert the user or pollute error reporting. + try { + const res = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=NS`, { + headers: { Accept: "application/dns-json" }, + }); + if (!res.ok) return null; + const json = (await res.json()) as { Answer?: { data?: string }[] }; + const ns = (json.Answer ?? []) + .map((a) => a.data) + .filter((d): d is string => typeof d === "string" && d.trim().length > 0) + .map((d) => d.trim().replace(/\.$/, "")); + return ns.length > 0 ? ns : null; + } catch { + return null; + } +} + +// Walk up labels (e.g. mail.shop.example.co.uk → shop.example.co.uk → example.co.uk → co.uk) +// and return the first ancestor whose authoritative NS records actually resolve. That ancestor +// is the DNS zone apex — robust to multi-label public suffixes (`.co.uk`, `.com.au`, …). +async function findZoneApex(subdomain: string): Promise<{ apex: string, nameServers: string[] } | null> { + const labels = subdomain.split(".").filter(Boolean); + for (let i = 1; i < labels.length; i++) { + const candidate = labels.slice(i).join("."); + const ns = await lookupAuthoritativeNameServers(candidate); + if (ns) return { apex: candidate, nameServers: ns }; + } + return null; +} + +function downloadCloudflareZoneFile(subdomain: string, apex: string, nameServerRecords: string[]) { + const fqdnSubdomain = `${subdomain}.`; + const lines = [ + `$ORIGIN ${apex}.`, + `$TTL 3600`, + ...nameServerRecords.map((r) => `${fqdnSubdomain}\t3600\tIN\tNS\t${r.replace(/\.$/, "")}.`), + "", + ]; + const blob = new Blob([lines.join("\n")], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${subdomain}-cloudflare.txt`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); return ( @@ -254,6 +321,24 @@ function ManagedDomainSetupDialog(props: { const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); const [checking, setChecking] = useState(false); + const [cloudflareApex, setCloudflareApex] = useState(null); + + useEffect(() => { + if (!props.open || stage !== 2 || !setupState) { + setCloudflareApex(null); + return; + } + let cancelled = false; + runAsynchronously(async () => { + const zone = await findZoneApex(setupState.subdomain); + if (cancelled || !zone) return; + const usesCloudflare = zone.nameServers.some((n) => /(^|\.)cloudflare\.com$/i.test(n)); + if (usesCloudflare) setCloudflareApex(zone.apex); + }); + return () => { + cancelled = true; + }; + }, [props.open, stage, setupState]); useEffect(() => { if (props.open) { @@ -354,7 +439,7 @@ function ManagedDomainSetupDialog(props: { return ( - + Add managed domain
@@ -426,19 +511,21 @@ function ManagedDomainSetupDialog(props: { Use a dedicated subdomain (e.g. emails.example.com), not your apex domain.
-
+
-
- { - setSenderLocalPart(e.target.value); - setStage1Error(null); - }} - type="text" - size="md" - /> - +
+
+ { + setSenderLocalPart(e.target.value); + setStage1Error(null); + }} + type="text" + size="md" + /> +
+ @{subdomain || "your-subdomain"}
@@ -449,8 +536,8 @@ function ManagedDomainSetupDialog(props: { {stage === 2 && setupState && ( <> -
-
+
+
Add these records to {setupState.subdomain}
@@ -458,8 +545,65 @@ function ManagedDomainSetupDialog(props: {
+ {cloudflareApex && ( +
+
+ +
+
+ Cloudflare detected on {cloudflareApex} +
+
+
+
+ { window.open(`https://dash.cloudflare.com/?to=/:account/${encodeURIComponent(cloudflareApex)}/dns/records`, "_blank", "noopener,noreferrer"); }} + > + Open + + downloadCloudflareZoneFile(setupState.subdomain, cloudflareApex, setupState.nameServerRecords)} + > + Download zone file + + + + + + +
Import on Cloudflare
+
    +
  1. Open your zone's DNS page on Cloudflare.
  2. +
  3. Click Import and Export in the top-right.
  4. +
  5. Under Import DNS records, click Select a file and pick the file you just downloaded.
  6. +
+ Cloudflare Import and Export dialog screenshot +
+
+
+
+ )} +
-
+
Type
Name
Content
@@ -468,14 +612,17 @@ function ManagedDomainSetupDialog(props: {
NS - {setupState.subdomain}
- {r} + {setupState.subdomain} + +
+
+ {r}
@@ -582,6 +729,7 @@ export function DomainSettings() { const [domains, setDomains] = useState([]); const [loadingDomains, setLoadingDomains] = useState(false); const [dialog, setDialog] = useState<{ initialState: SetupState | null } | null>(null); + const [confirmDelete, setConfirmDelete] = useState(null); const refreshDomains = useCallback(async () => { setLoadingDomains(true); @@ -887,11 +1035,11 @@ export function DomainSettings() { const displayStatus: ManagedDomainStatus = isInUse ? "applied" : isReadyButUnused ? "verified" : d.status; const displayLabel = isInUse ? "Active" : MANAGED_DOMAIN_STATUS_LABELS[displayStatus]; return ( -
-
+
+
-
-
+
+
{d.senderLocalPart}@{d.subdomain}
@@ -901,7 +1049,7 @@ export function DomainSettings() {
{displayLabel} @@ -934,6 +1082,16 @@ export function DomainSettings() { View DNS )} + {!isInUse && ( + + )}
); @@ -1015,6 +1173,27 @@ export function DomainSettings() { initialState={dialog?.initialState ?? null} onCompleted={() => { runAsynchronouslyWithAlert(refreshDomains); }} /> + + { if (!o) setConfirmDelete(null); }} + title="Remove managed domain" + danger + okButton={{ + label: "Remove", + onClick: async () => { + if (!confirmDelete) return; + await stackAdminApp.deleteManagedEmailDomain({ resendDomainId: confirmDelete.domainId }); + toast({ title: "Domain removed", description: `${confirmDelete.senderLocalPart}@${confirmDelete.subdomain} was removed.`, variant: "success" }); + await refreshDomains(); + }, + }} + cancelButton + > + + Remove {confirmDelete?.senderLocalPart}@{confirmDelete?.subdomain}? This permanently removes the domain from our records and the underlying email provider. You can re-add it later, but you'll need to re-verify DNS. + + ); } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/managed-email-onboarding.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/managed-email-onboarding.test.ts index c248c1b0b7..54e20fc698 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/managed-email-onboarding.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/managed-email-onboarding.test.ts @@ -125,4 +125,175 @@ describe("managed email onboarding internal endpoints", () => { }); expect(config.emails.server.password).toEqual(expect.stringMatching(/^managed_mock_key_/)); }); + + it("rejects client access for delete endpoint", async ({ expect }) => { + await Project.createAndSwitch(); + + const response = await niceBackendFetch("/api/v1/internal/emails/managed-onboarding/delete", { + method: "POST", + accessType: "client", + body: { + resend_domain_id: "managed_mock_domain", + }, + }); + + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 401, + "body": { + "code": "INSUFFICIENT_ACCESS_TYPE", + "details": { + "actual_access_type": "client", + "allowed_access_types": ["admin"], + }, + "error": "The x-stack-access-type header must be 'admin', but was 'client'.", + }, + "headers": Headers { + "x-stack-known-error": "INSUFFICIENT_ACCESS_TYPE", +