Skip to content

Commit 2f71990

Browse files
authored
Redesign Email Server settings + managed domain flow (#1373)
## Summary Rewrites the **Email Server** section of the project email settings page and the managed-domain setup flow. Replaces the dropdown + conditional-fields layout with a visual four-card picker, a clearer unsaved-state model, a stepper dialog for managed-domain onboarding, and a consistent tracked-domains list. Also fixes two data-correctness bugs in the managed-domain backend. ## Walkthrough (2×, dead-frames trimmed) ![walkthrough](https://raw.githubusercontent.com/stack-auth/stack-auth/pr-assets-email-ui/pr-assets-walkthrough.gif) ## Before The saved state was a minimal dropdown, but choosing Custom SMTP / Resend revealed a long conditional form with a hidden gear toggle for server config, no clear "what is saved" signal, and a separate dialog pattern for managed domains. | Saved (Managed) | Custom SMTP selected | |---|---| | ![before-managed](https://raw.githubusercontent.com/stack-auth/stack-auth/pr-assets-email-ui/pr-assets-01-before-shared.png) | ![before-smtp](https://raw.githubusercontent.com/stack-auth/stack-auth/pr-assets-email-ui/pr-assets-02-before-smtp.png) | ## After — Provider cards Four visual cards (Stack Shared, Managed Domain, Resend, Custom SMTP) with updated copy. The saved provider shows a green **Current** pill; the card the user is previewing shows an amber dashed **Draft** pill. An amber unsaved-changes banner appears between the picker and the form when state diverges from saved, so it is unambiguous that a click is not yet committed. | Saved state | Previewing a different provider | |---|---| | ![after-saved](https://raw.githubusercontent.com/stack-auth/stack-auth/pr-assets-email-ui/pr-assets-03-after-saved.png) | ![after-draft](https://raw.githubusercontent.com/stack-auth/stack-auth/pr-assets-email-ui/pr-assets-04-after-draft.png) | Copy changes: - **Stack Shared** — "Only default emails — no custom templates, themes, or sender identity." (was: "Shared (noreply@stackframe.co)") - **Managed Domain** — "Bring your own domain. You add DNS records; we handle signing & delivery." (was: "Managed (via managed domain setup)") - **Resend** uses the official Resend brand mark (light/dark variants in `apps/dashboard/public/assets/`) ## After — Managed domain list + stepper dialog Selecting **Managed Domain** immediately shows the tracked-domain list with an **Add domain** button. Each row reflects real status (Active / Verified / Waiting for DNS / Verifying / Failed). Exactly one domain can be **Active** — the one matching the saved email config; every other verified/applied domain shows a **Use this domain** button so switching is always possible. Adding a domain opens a 3-stage dialog with a horizontal stepper (Verify is right-aligned for the final step). Stage 2 replaces the old bare NS-list with a proper **Type / Name / Content** DNS records table with per-row copy buttons. | Tracked domains list | DNS records table | |---|---| | ![after-list](https://raw.githubusercontent.com/stack-auth/stack-auth/pr-assets-email-ui/pr-assets-05-after-managed-list.png) | ![after-dns-table](https://raw.githubusercontent.com/stack-auth/stack-auth/pr-assets-email-ui/pr-assets-06-after-dns-table.png) | ## Bug fixes - **Backend: applying a managed domain did not demote previously-applied ones.** Multiple rows could end up with status `APPLIED` even though only one could be in the saved config. New helper `demoteOtherAppliedManagedEmailDomains({ tenancyId, keepId })` runs inside `applyManagedEmailProvider` to demote all other applied rows in the tenancy back to `VERIFIED` before marking the new one. - **Frontend: "Use this domain" only appeared for `status === verified`.** A domain that had been applied then replaced could never be re-applied from the UI. Button now appears for any `verified` or `applied` row that is not currently in use; the **Active** label is derived from config match instead of DB status. - **Dev mock onboarding now mirrors production timing.** `shouldUseMockManagedEmailOnboarding()` used to insert domains as `verified` synchronously. Now the domain is created as `pending_verification`, and a fire-and-forget `runAsynchronously(() => wait(1000))` updates it to `verified` — mirroring the real Resend webhook flow so the UI states (pending → verifying → verified) are exercised in local dev. ## Test plan - [ ] Cards: clicking each card shows `Draft` pill + amber banner; Discard restores; Save commits and flips `Current` to the new card - [ ] Managed: Add domain → stage 1 input → stage 2 DNS table + copy → Check verification flips to stage 3 → Use this domain sets it Active and demotes the previously-active domain in the list - [ ] Managed: clicking **Use this domain** on a non-active verified row makes it Active and the previously-active row back to Verified - [ ] Shared / Resend / SMTP: existing save + test-email flows still work (logic preserved verbatim) - [ ] `pnpm typecheck` (dashboard + backend) and `pnpm lint` pass <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Redesigned email domain setup flow with multi-step verification dialog * Added copy-to-clipboard for DNS records * Enhanced provider selection interface with improved visual presentation * Onboarding now shows initial "pending verification" state and completes verification asynchronously * **Bug Fixes** * Ensures only one managed domain becomes active when applying a domain * Improved error handling for email configuration saves * **Tests** * Updated end-to-end tests to reflect async verification timing <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent d1751a7 commit 2f71990

6 files changed

Lines changed: 781 additions & 416 deletions

File tree

apps/backend/src/lib/managed-email-domains.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,22 @@ export async function updateManagedEmailDomainWebhookStatus(options: {
181181
return mapRow(rows[0]!);
182182
}
183183

184+
export async function demoteOtherAppliedManagedEmailDomains(options: {
185+
tenancyId: string,
186+
keepId: string,
187+
}): Promise<void> {
188+
await globalPrismaClient.$queryRaw(Prisma.sql`
189+
UPDATE "ManagedEmailDomain"
190+
SET
191+
"status" = 'VERIFIED'::"ManagedEmailDomainStatus",
192+
"appliedAt" = NULL,
193+
"updatedAt" = CURRENT_TIMESTAMP
194+
WHERE "tenancyId" = ${options.tenancyId}
195+
AND "id" <> ${options.keepId}
196+
AND "status" = 'APPLIED'::"ManagedEmailDomainStatus"
197+
`);
198+
}
199+
184200
export async function markManagedEmailDomainApplied(id: string): Promise<ManagedEmailDomain> {
185201
const rows = await globalPrismaClient.$queryRaw<ManagedEmailDomainRow[]>(Prisma.sql`
186202
UPDATE "ManagedEmailDomain"

apps/backend/src/lib/managed-email-onboarding.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
ManagedEmailDomain,
44
ManagedEmailDomainStatus,
55
createManagedEmailDomain,
6+
demoteOtherAppliedManagedEmailDomains,
67
getManagedEmailDomainByResendDomainId,
78
getManagedEmailDomainByTenancyAndSubdomain,
89
listManagedEmailDomainsForTenancy,
@@ -12,6 +13,7 @@ import {
1213
import { Tenancy } from "@/lib/tenancies";
1314
import { getNodeEnvironment, getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
1415
import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
16+
import { runAsynchronously, wait } from "@stackframe/stack-shared/dist/utils/promises";
1517

1618
type ResendDomainRecord = {
1719
record: string,
@@ -543,7 +545,16 @@ export async function setupManagedEmailProvider(options: { subdomain: string, se
543545
senderLocalPart: options.senderLocalPart,
544546
resendDomainId: `managed_mock_${options.tenancy.id}_${normalizedSubdomain}`.replace(/[^a-zA-Z0-9_-]/g, "_"),
545547
nameServerRecords: ["ns1.dnsimple.com", "ns2.dnsimple.com"],
546-
status: "verified",
548+
status: "pending_verification",
549+
});
550+
runAsynchronously(async () => {
551+
await wait(1000);
552+
await updateManagedEmailDomainWebhookStatus({
553+
resendDomainId: row.resendDomainId,
554+
providerStatusRaw: "verified",
555+
status: "verified",
556+
lastError: null,
557+
});
547558
});
548559
return managedDomainToSetupResult(row);
549560
}
@@ -631,6 +642,10 @@ export async function applyManagedEmailProvider(options: {
631642
senderLocalPart: domain.senderLocalPart,
632643
});
633644

645+
await demoteOtherAppliedManagedEmailDomains({
646+
tenancyId: options.tenancy.id,
647+
keepId: domain.id,
648+
});
634649
await markManagedEmailDomainApplied(domain.id);
635650
return { status: "applied" };
636651
}
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)