From 7d5adeb06e96aa5d3d2b0c7a56bb04522e522a71 Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Tue, 2 Jun 2026 16:52:51 -0700 Subject: [PATCH 1/5] fix(email): DNSimple zone teardown, managed-domain apply flow (#1527) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Three related fixes, surfaced while investigating a production error on managed email onboarding. ### 1. DNSimple managed-email zone deletion (the reported production error) `deleteDnsimpleZoneByName` issued `DELETE /v2/{account}/zones/{zone}`, but **DNSimple's v2 API has no DELETE endpoint for zones**. Zones are created here by creating a *domain* (`POST /domains`), so the symmetric teardown is `DELETE /domains/{name}`, which also removes the hosted zone. The old call returned a non-OK status, throwing: > `HexclaveAssertionError: DNSimple returned non-OK status when deleting managed email zone` on every managed-domain deletion (the Resend domain was already deleted by then, so the request 500s and the DNSimple zone leaks). Now deletes the owning domain. ### 2. `applyManagedEmailProvider` left configs in an invalid state The apply early-returned on `domain.status === "applied"` **without rewriting config**. If the config had since drifted (e.g. the user switched to a shared/other provider and back), re-applying never restored `password`/`senderName`, so the *rendered* config was invalid and **every `GET /internal/projects` 500'd** with `Result admin validation failed in CRUD handler` (dashboard then loops/refreshes). Now it only short-circuits when the config actually uses the domain (`isManagedEmailDomainInUseForTenancy`); otherwise it re-provisions and writes the full valid config. ### 3. Email-settings dashboard: managed domain now uses the staged save flow Clicking "Use this domain" fired an immediate API call and only refreshed the domains *list* — never the project config the UI derives "active" from — so nothing visibly changed and it appeared not to persist. Applying a managed domain now matches the other providers: selecting a domain stages a draft and shows the standard **"Unsaved changes → Save"** card; **Save** calls `applyManagedEmailProvider` (which owns the full config write) and then refreshes the reactive config cache so the UI flips to **Active**. ### 4. Build unblock: `@stackframe/stack-shared` → `@hexclave/shared` The `@hexclave/*` rename (#1482) missed `createGlobal`'s import in two template providers, and PR 3 deleted the compat alias — so `pnpm build:packages` couldn't resolve `@stackframe/stack-shared/dist/utils/globals` when building the dashboard. Fixed in the template (generated SDKs follow). *(Independent of the email fixes, but required to build the branch.)* ## Files - `apps/backend/src/lib/managed-email-onboarding.tsx` — DNSimple domain delete + apply re-provision-on-drift - `apps/dashboard/.../email-settings/domain-settings.tsx` — staged managed-domain apply - `packages/template/src/providers/{stack-context,translation-provider-client}.tsx` — globals import rename ## Testing - `tsc --noEmit` and `eslint` clean on `@hexclave/backend` and `@hexclave/dashboard`. - `pnpm build:packages` + `pnpm codegen` succeed; dashboard email-settings route compiles and serves `200`. - Reproduced the 500 loop locally, root-caused it to a partial managed config override, and reset the affected project's `emails.server` override to recover. ## Notes - Applying a managed domain still calls the API once (to mint the scoped Resend sending key) — that call now happens on **Save** rather than on click. - The older `apps/dashboard/.../emails/page-client.tsx` has the same immediate-apply pattern; left untouched pending confirmation that screen is still in use. --- ## Summary by cubic Fixes managed email onboarding: deletes DNSimple domains correctly, restores a valid managed config on re-apply, and moves managed-domain apply to a staged Save that waits for the domain list and shows selection state. Also updates imports to `@hexclave/shared` to unblock builds. - **Bug Fixes** - DNSimple teardown: use DELETE `/domains/{name}` (zones have no DELETE). Stops errors and zone leaks when removing managed domains. - `applyManagedEmailProvider`: only short-circuits when the rendered config already uses the domain; otherwise re-provisions and writes the full managed config to prevent invalid renders and 500s. - Dashboard: managed domain selection now stages a draft; Save calls `applyManagedEmailProvider`, refreshes the config cache, and the UI updates. Shows “Selected — save to apply,” supports deselecting, requires a selection, and disables Save until the domains list finishes loading. - **Dependencies** - Renamed globals import from `@stackframe/stack-shared` to `@hexclave/shared` to restore `pnpm build:packages`. Written for commit 6ecb6a1ff8d104364eaaea4226d7d381e45f0e51. Summary will update on new commits. Review in cubic ## Summary by CodeRabbit * **Bug Fixes** * Improved detection and recovery for managed email configuration drift scenarios * Fixed teardown behavior to avoid leaving inconsistent managed domain state * **New Features** * Managed domain selection now requires explicit save confirmation before applying * Added status labels showing current vs staged domain and pending changes * Added ability to deselect staged managed domains; deleting a domain clears any draft * Save now shows applied sender identity via toast after applying managed domain --- .../src/lib/managed-email-onboarding.tsx | 20 ++- .../email-settings/domain-settings.tsx | 152 ++++++++++++------ 2 files changed, 121 insertions(+), 51 deletions(-) diff --git a/apps/backend/src/lib/managed-email-onboarding.tsx b/apps/backend/src/lib/managed-email-onboarding.tsx index 854783748..4c0c9cd49 100644 --- a/apps/backend/src/lib/managed-email-onboarding.tsx +++ b/apps/backend/src/lib/managed-email-onboarding.tsx @@ -230,7 +230,10 @@ 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)}`, { + // A zone is created by creating a domain (see createDnsimpleZone), and DNSimple has no + // DELETE endpoint for zones — the symmetric teardown is to delete the owning domain, + // which removes its hosted DNS zone too. + const response = await fetch(`${dnsimpleBaseUrl}/${encodeURIComponent(dnsimpleAccountId)}/domains/${encodeURIComponent(zoneName)}`, { method: "DELETE", headers: getDnsimpleHeaders(), }); @@ -239,7 +242,7 @@ async function deleteDnsimpleZoneByName(zoneName: string): Promise<{ status: "de } if (!response.ok) { const responseBody = await response.text(); - throw new HexclaveAssertionError(`DNSimple returned non-OK status when deleting managed email zone`, { + throw new HexclaveAssertionError(`DNSimple returned non-OK status when deleting managed email domain`, { zoneName, status: response.status, responseBody, @@ -643,10 +646,19 @@ export async function applyManagedEmailProvider(options: { if (!domain || domain.tenancyId !== options.tenancy.id || !domain.isActive) { throw new StatusError(404, "Managed domain not found for this project/branch"); } - if (domain.status === "applied") { + // Only short-circuit when the rendered config already points at this exact domain. + // If the row is marked "applied" but the config has since drifted (e.g. the user + // switched to a different provider and back), we must fall through and re-provision + // so the config is rewritten with the full, valid managed server settings — otherwise + // the config is left missing required fields like password/senderName. + if (domain.status === "applied" && isManagedEmailDomainInUseForTenancy({ + tenancy: options.tenancy, + subdomain: domain.subdomain, + senderLocalPart: domain.senderLocalPart, + })) { return { status: "applied" }; } - if (domain.status !== "verified") { + if (domain.status !== "verified" && domain.status !== "applied") { throw new StatusError(409, "Managed domain is not verified yet"); } 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 c66b1b2b7..5e0888509 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 @@ -310,6 +310,7 @@ function ManagedDomainSetupDialog(props: { onOpenChange: (open: boolean) => void, initialState: SetupState | null, onCompleted: () => void, + onApply: (domainId: string) => void, }) { const stackAdminApp = useAdminApp(); const { toast } = useToast(); @@ -416,21 +417,14 @@ function ManagedDomainSetupDialog(props: { } }, [setupState, stackAdminApp, toast, props]); - const handleApply = useCallback(async () => { + const handleApply = useCallback(() => { if (!setupState) return; - setSubmitting(true); - setError(null); - try { - await stackAdminApp.applyManagedEmailProvider({ domainId: setupState.domainId }); - toast({ title: "Domain applied", description: `Sending emails from ${setupState.senderLocalPart}@${setupState.subdomain}.`, variant: "success" }); - props.onCompleted(); - props.onOpenChange(false); - } catch (e) { - setError(e instanceof Error ? e.message : "Could not apply domain"); - } finally { - setSubmitting(false); - } - }, [setupState, stackAdminApp, toast, props]); + // Don't apply immediately — stage the selection so the parent surfaces the + // standard "unsaved changes" save bar, consistent with the other providers. + // The actual applyManagedEmailProvider call happens when the user hits Save. + props.onApply(setupState.domainId); + props.onOpenChange(false); + }, [setupState, props]); const steps = [ { n: 1 as const, title: "Your domain" }, @@ -698,8 +692,7 @@ function ManagedDomainSetupDialog(props: { {stage === 3 && ( runAsynchronouslyWithAlert(handleApply)} + onClick={() => handleApply()} > Use this domain @@ -731,6 +724,9 @@ export function DomainSettings() { const [loadingDomains, setLoadingDomains] = useState(false); const [dialog, setDialog] = useState<{ initialState: SetupState | null } | null>(null); const [confirmDelete, setConfirmDelete] = useState(null); + // The managed domain the user has staged to apply but not yet saved. `null` means + // "no pending change" — the active domain (from config) is used as the baseline. + const [draftManagedDomainId, setDraftManagedDomainId] = useState(null); const refreshDomains = useCallback(async () => { setLoadingDomains(true); @@ -748,6 +744,15 @@ export function DomainSettings() { } }, [serverType, refreshDomains]); + // The managed domain currently applied per the saved config (the baseline). + const activeManagedDomainId = emailConfig.provider === "managed" && emailConfig.managedSubdomain && emailConfig.managedSenderLocalPart + ? domains.find((d) => + d.subdomain === emailConfig.managedSubdomain + && d.senderLocalPart === emailConfig.managedSenderLocalPart + )?.domainId ?? null + : null; + const isManagedDirty = serverType === "managed" && draftManagedDomainId != null && draftManagedDomainId !== activeManagedDomainId; + const isShared = serverType === "shared"; const visibleSenderFields = serverType === "resend" || serverType === "standard"; @@ -768,6 +773,7 @@ export function DomainSettings() { const isDirty = useMemo(() => { if (serverType !== savedServerType) return true; + if (isManagedDirty) return true; const keys = new Set(); if (visibleSenderFields) { keys.add("senderEmail"); @@ -778,7 +784,7 @@ export function DomainSettings() { if ((formValues[k] || "") !== (savedValues[k] || "")) return true; } return false; - }, [serverType, savedServerType, formValues, savedValues, visibleSenderFields, configFields]); + }, [serverType, savedServerType, isManagedDirty, formValues, savedValues, visibleSenderFields, configFields]); const updateField = useCallback((key: string, value: string) => { setFormValues(prev => ({ ...prev, [key]: value })); @@ -793,6 +799,7 @@ export function DomainSettings() { const handleDiscard = useCallback(() => { setServerType(savedServerType); setFormValues(savedValues); + setDraftManagedDomainId(null); setSaveError(null); }, [savedServerType, savedValues]); @@ -809,6 +816,22 @@ export function DomainSettings() { pushable: false, }); toast({ title: "Email server updated", variant: "success" }); + } else if (serverType === "managed") { + const domainId = draftManagedDomainId; + if (!domainId) throwErr("Select a domain to use"); + const selected = domains.find((d) => d.domainId === domainId); + if (!selected) throwErr("Selected domain is no longer available"); + // applyManagedEmailProvider provisions the scoped sending key and writes the complete, + // valid managed config server-side — it owns the managed config entirely. We must NOT + // write a partial config from here: a partial managed override renders to a config + // missing required fields (password/senderName), which breaks every project read. + // The empty updateConfig is a no-op write whose only purpose is to refresh the reactive + // config cache (via _refreshProjectConfig) so the UI reflects the newly-active domain. + await stackAdminApp.applyManagedEmailProvider({ domainId }); + await updateConfig({ adminApp: stackAdminApp, configUpdate: {}, pushable: false }); + setDraftManagedDomainId(null); + await refreshDomains(); + toast({ title: "Domain applied", description: `Sending emails from ${selected.senderLocalPart}@${selected.subdomain}.`, variant: "success" }); } else { const requireField = (key: string, label: string): string => { const val = formValues[key]; @@ -875,7 +898,7 @@ export function DomainSettings() { } finally { setSaving(false); } - }, [serverType, formValues, stackAdminApp, updateConfig, toast]); + }, [serverType, formValues, stackAdminApp, updateConfig, toast, draftManagedDomainId, domains, refreshDomains]); if (isEmulator) { return ( @@ -898,12 +921,15 @@ export function DomainSettings() { ...configFields.filter(f => !(formValues[f.key] || "").trim()).map(f => f.label), ] : []; - const canSave = isDirty && !emailFormatError && missingRequiredFields.length === 0; - const activeManagedDomainId = emailConfig.provider === "managed" && emailConfig.managedSubdomain && emailConfig.managedSenderLocalPart - ? domains.find((d) => - d.subdomain === emailConfig.managedSubdomain - && d.senderLocalPart === emailConfig.managedSenderLocalPart - )?.domainId + // Managed requires a staged domain selection before it can be saved. + const canSave = isDirty && !emailFormatError && missingRequiredFields.length === 0 + // For managed, also wait until the domains list has finished loading: a just-staged + // domain (e.g. one added via the setup dialog, which kicks off refreshDomains) may not + // be in `domains` yet, and saving early would spuriously fail with "Selected domain is + // no longer available". loadingDomains flips true synchronously when a refresh starts. + && (serverType !== "managed" || (draftManagedDomainId != null && !loadingDomains)); + const draftManagedDomain = draftManagedDomainId != null + ? domains.find((d) => d.domainId === draftManagedDomainId) ?? null : null; return ( @@ -971,14 +997,27 @@ export function DomainSettings() { })} - {isDirty && serverType !== "managed" && ( + {isDirty && (
- Unsaved changes — previewing{" "} - {PROVIDERS.find((p) => p.value === serverType)?.label}. - Changes don't take effect until you save. + {serverType === "managed" ? ( + draftManagedDomain ? ( + <> + Unsaved changes — save to start sending from{" "} + {draftManagedDomain.senderLocalPart}@{draftManagedDomain.subdomain}. + + ) : ( + <>Switching to a managed domain — select a verified domain below, then save. + ) + ) : ( + <> + Unsaved changes — previewing{" "} + {PROVIDERS.find((p) => p.value === serverType)?.label}. + Changes don't take effect until you save. + + )}
@@ -1030,11 +1069,12 @@ export function DomainSettings() { ) : (
{domains.map((d) => { - const isInUse = d.domainId === activeManagedDomainId; - const isReadyButUnused = !isInUse && (d.status === "verified" || d.status === "applied"); + const isActiveSaved = d.domainId === activeManagedDomainId; + const isDraftSelected = draftManagedDomainId != null && d.domainId === draftManagedDomainId && draftManagedDomainId !== activeManagedDomainId; + const isReadyButUnused = !isActiveSaved && !isDraftSelected && (d.status === "verified" || d.status === "applied"); const isPending = d.status === "pending_dns" || d.status === "pending_verification" || d.status === "failed"; - const displayStatus: ManagedDomainStatus = isInUse ? "applied" : isReadyButUnused ? "verified" : d.status; - const displayLabel = isInUse ? "Active" : MANAGED_DOMAIN_STATUS_LABELS[displayStatus]; + const displayStatus: ManagedDomainStatus = isActiveSaved ? "applied" : isReadyButUnused ? "verified" : d.status; + const displayLabel = isActiveSaved ? "Active" : MANAGED_DOMAIN_STATUS_LABELS[displayStatus]; return (
@@ -1044,30 +1084,41 @@ export function DomainSettings() { {d.senderLocalPart}@{d.subdomain}
- {isInUse ? "In use for this project" : "Not in use"} + {isActiveSaved ? "In use for this project" : isDraftSelected ? "Selected — save to apply" : "Not in use"}
- - {displayLabel} - + {isDraftSelected ? ( + + Selected + + ) : ( + + {displayLabel} + + )} {isReadyButUnused && ( runAsynchronouslyWithAlert(async () => { - await stackAdminApp.applyManagedEmailProvider({ domainId: d.domainId }); - toast({ title: "Domain applied", description: `Sending from ${d.senderLocalPart}@${d.subdomain}.`, variant: "success" }); - await refreshDomains(); - })} + onClick={() => setDraftManagedDomainId(d.domainId)} > Use this domain )} + {isDraftSelected && ( + setDraftManagedDomainId(null)} + > + Deselect + + )} {isPending && ( )} - {!isInUse && ( + {!isActiveSaved && (