diff --git a/apps/backend/src/lib/managed-email-domains.tsx b/apps/backend/src/lib/managed-email-domains.tsx index 0445504dbe..c31c3960c6 100644 --- a/apps/backend/src/lib/managed-email-domains.tsx +++ b/apps/backend/src/lib/managed-email-domains.tsx @@ -181,6 +181,22 @@ export async function updateManagedEmailDomainWebhookStatus(options: { return mapRow(rows[0]!); } +export async function demoteOtherAppliedManagedEmailDomains(options: { + tenancyId: string, + keepId: string, +}): Promise { + await globalPrismaClient.$queryRaw(Prisma.sql` + UPDATE "ManagedEmailDomain" + SET + "status" = 'VERIFIED'::"ManagedEmailDomainStatus", + "appliedAt" = NULL, + "updatedAt" = CURRENT_TIMESTAMP + WHERE "tenancyId" = ${options.tenancyId} + AND "id" <> ${options.keepId} + AND "status" = 'APPLIED'::"ManagedEmailDomainStatus" + `); +} + export async function markManagedEmailDomainApplied(id: string): Promise { const rows = await globalPrismaClient.$queryRaw(Prisma.sql` UPDATE "ManagedEmailDomain" diff --git a/apps/backend/src/lib/managed-email-onboarding.tsx b/apps/backend/src/lib/managed-email-onboarding.tsx index c3ce3346d7..11eb3f051a 100644 --- a/apps/backend/src/lib/managed-email-onboarding.tsx +++ b/apps/backend/src/lib/managed-email-onboarding.tsx @@ -3,6 +3,7 @@ import { ManagedEmailDomain, ManagedEmailDomainStatus, createManagedEmailDomain, + demoteOtherAppliedManagedEmailDomains, getManagedEmailDomainByResendDomainId, getManagedEmailDomainByTenancyAndSubdomain, listManagedEmailDomainsForTenancy, @@ -12,6 +13,7 @@ import { import { Tenancy } from "@/lib/tenancies"; import { getNodeEnvironment, getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { runAsynchronously, wait } from "@stackframe/stack-shared/dist/utils/promises"; type ResendDomainRecord = { record: string, @@ -543,7 +545,16 @@ export async function setupManagedEmailProvider(options: { subdomain: string, se senderLocalPart: options.senderLocalPart, resendDomainId: `managed_mock_${options.tenancy.id}_${normalizedSubdomain}`.replace(/[^a-zA-Z0-9_-]/g, "_"), nameServerRecords: ["ns1.dnsimple.com", "ns2.dnsimple.com"], - status: "verified", + status: "pending_verification", + }); + runAsynchronously(async () => { + await wait(1000); + await updateManagedEmailDomainWebhookStatus({ + resendDomainId: row.resendDomainId, + providerStatusRaw: "verified", + status: "verified", + lastError: null, + }); }); return managedDomainToSetupResult(row); } @@ -631,6 +642,10 @@ export async function applyManagedEmailProvider(options: { senderLocalPart: domain.senderLocalPart, }); + await demoteOtherAppliedManagedEmailDomains({ + tenancyId: options.tenancy.id, + keepId: domain.id, + }); await markManagedEmailDomainApplied(domain.id); return { status: "applied" }; } diff --git a/apps/dashboard/public/assets/resend-icon-black.svg b/apps/dashboard/public/assets/resend-icon-black.svg new file mode 100644 index 0000000000..67af6bf6d6 --- /dev/null +++ b/apps/dashboard/public/assets/resend-icon-black.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/dashboard/public/assets/resend-icon-white.svg b/apps/dashboard/public/assets/resend-icon-white.svg new file mode 100644 index 0000000000..4371f60b33 --- /dev/null +++ b/apps/dashboard/public/assets/resend-icon-white.svg @@ -0,0 +1,3 @@ + + + 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 2ab1557af7..cafd59cf82 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 @@ -2,84 +2,77 @@ import { FormDialog } from "@/components/form-dialog"; import { InputField } from "@/components/form-fields"; -import { DesignCard } from "@/components/design-components"; +import { + DesignAlert, + DesignButton, + DesignCard, + DesignInput, +} from "@/components/design-components"; import { useUpdateConfig } from "@/lib/config-update"; import { getPublicEnvVar } from "@/lib/env"; import { cn } from "@/lib/utils"; import { AdminEmailConfig } from "@stackframe/stack"; import { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; -import { strictEmailSchema } from "@stackframe/stack-shared/dist/schema-fields"; -import { ArrowsClockwise, Envelope, GearSix, GlobeSimple, PaperPlaneTilt } from "@phosphor-icons/react"; +import { + ArrowLeft, + ArrowsClockwise, + CheckCircle, + Cloud, + CopySimple, + Envelope, + GlobeSimple, + HardDrives, + type Icon as PhosphorIcon, + PaperPlaneTilt, + Plus, + ShieldCheck, + Spinner, + WarningDiamond, +} from "@phosphor-icons/react"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; -import { DesignAlert } from "@/components/design-components"; -import { DesignButton } from "@/components/design-components"; -import { DesignInput } from "@/components/design-components"; -import { DesignSelectorDropdown } from "@/components/design-components"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Label, Typography, useToast } from "@/components/ui"; -import { SimpleTooltip } from "@/components/ui/simple-tooltip"; -import { useCallback, useMemo, useState } from "react"; +import { Dialog, DialogContent, DialogTitle, Label, Typography, useToast } from "@/components/ui"; +import { useCallback, useEffect, useMemo, useState } from "react"; import * as yup from "yup"; +import Image from "next/image"; import { useAdminApp } from "../use-admin-app"; type ServerType = "shared" | "managed" | "resend" | "standard"; type ManagedDomainStatus = "pending_dns" | "pending_verification" | "verified" | "applied" | "failed"; -type ServerFieldConfig = { - label: string, - key: string, - type: "text" | "email" | "number" | "password", +type ManagedDomain = { + domainId: string, + subdomain: string, + senderLocalPart: string, + status: ManagedDomainStatus, + nameServerRecords: string[], }; -const SERVER_TYPE_LABELS: Record = { - shared: "Shared (noreply@stackframe.co)", - managed: "Managed (via managed domain setup)", - resend: "Resend", - standard: "Custom SMTP", +type SetupState = { + domainId: string, + subdomain: string, + senderLocalPart: string, + nameServerRecords: string[], + status: ManagedDomainStatus, }; const MANAGED_DOMAIN_STATUS_LABELS: Record = { - pending_dns: "Pending DNS records", - pending_verification: "Pending verification", + pending_dns: "Waiting for DNS", + pending_verification: "Verifying…", verified: "Verified", - applied: "Applied", + applied: "Active", failed: "Failed", }; -const VISIBLE_FIELDS: Record = { - shared: [], - managed: [], - resend: [ - { label: "Sender Email", key: "senderEmail", type: "email" }, - { label: "Sender Name", key: "senderName", type: "text" }, - ], - standard: [ - { label: "Sender Email", key: "senderEmail", type: "email" }, - { label: "Sender Name", key: "senderName", type: "text" }, - ], -}; - -const CONFIG_FIELDS: Record = { - shared: [], - managed: [], - resend: [ - { label: "Resend API Key", key: "password", type: "password" }, - ], - standard: [ - { label: "Host", key: "host", type: "text" }, - { label: "Port", key: "port", type: "number" }, - { label: "Username", key: "username", type: "text" }, - { label: "Password", key: "password", type: "password" }, - ], +const MANAGED_DOMAIN_STATUS_COLORS: Record = { + pending_dns: "text-amber-600 dark:text-amber-400 bg-amber-500/10", + pending_verification: "text-blue-600 dark:text-blue-400 bg-blue-500/10", + verified: "text-green-600 dark:text-green-400 bg-green-500/10", + applied: "text-green-600 dark:text-green-400 bg-green-500/10", + failed: "text-red-600 dark:text-red-400 bg-red-500/10", }; -function maskSecret(value: string): string { - if (value.length <= 4) return "••••"; - return "••••••••" + value.slice(-4); -} - function getServerTypeFromConfig(config: CompleteConfig["emails"]["server"]): ServerType { if (config.isShared) return "shared"; if (config.provider === "managed") return "managed"; @@ -112,6 +105,64 @@ function getFormValuesFromConfig(config: CompleteConfig["emails"]["server"], pro }; } +function ResendIcon({ className }: { className?: string }) { + return ( + <> + + + + ); +} + +type ProviderMeta = { + value: ServerType, + label: string, + tagline: string, + icon?: PhosphorIcon, + customIcon?: React.ReactNode, +}; + +const PROVIDERS: ProviderMeta[] = [ + { + value: "shared", + label: "Stack Shared", + tagline: "Only default emails — no custom templates, themes, or sender identity.", + icon: Cloud, + }, + { + value: "managed", + label: "Managed Domain", + tagline: "Bring your own domain. You add DNS records; we handle signing & delivery.", + icon: ShieldCheck, + }, + { + value: "resend", + label: "Resend", + tagline: "Connect a Resend account with an API key.", + customIcon: , + }, + { + value: "standard", + label: "Custom SMTP", + tagline: "SendGrid, Postmark, AWS SES — any SMTP.", + icon: HardDrives, + }, +]; + function TestSendingDialog(props: { trigger: React.ReactNode }) { const stackAdminApp = useAdminApp(); const project = stackAdminApp.useProject(); @@ -149,207 +200,366 @@ function TestSendingDialog(props: { trigger: React.ReactNode }) { ); } -const managedEmailSetupSchema = yup.object({ - subdomain: yup - .string() - .trim() - .defined("Managed subdomain is required") - .test( - "non-empty-subdomain", - "Managed subdomain is required", - (value) => value.trim().length > 0, - ) - .matches( - /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9-]{2,63}$/, - "Enter a full subdomain like emails.example.com", - ), - senderLocalPart: yup - .string() - .trim() - .defined("Sender local part is required") - .test( - "non-empty-sender-local-part", - "Sender local part is required", - (value) => value.trim().length > 0, - ), -}); - -function ManagedEmailSetupDialog(props: { trigger: React.ReactNode }) { +const subdomainSchema = yup + .string() + .trim() + .defined("Managed subdomain is required") + .test("non-empty-subdomain", "Managed subdomain is required", (value) => value.trim().length > 0) + .matches( + /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9-]{2,63}$/, + "Enter a full subdomain like emails.example.com", + ); + +const senderLocalPartSchema = yup + .string() + .trim() + .defined("Sender local part is required") + .test("non-empty", "Sender local part is required", (value) => value.trim().length > 0); + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +function ManagedDomainSetupDialog(props: { + open: boolean, + onOpenChange: (open: boolean) => void, + initialState: SetupState | null, + onCompleted: () => void, +}) { const stackAdminApp = useAdminApp(); - const [open, setOpen] = useState(false); - const [setupState, setSetupState] = useState<{ - domainId: string, - nameServerRecords: string[], - subdomain: string, - senderLocalPart: string, - status: ManagedDomainStatus, - } | null>(null); - const [domains, setDomains] = useState>([]); + const { toast } = useToast(); + const [stage, setStage] = useState<1 | 2 | 3>(props.initialState ? 2 : 1); + const [subdomain, setSubdomain] = useState(props.initialState?.subdomain ?? ""); + const [senderLocalPart, setSenderLocalPart] = useState(props.initialState?.senderLocalPart ?? "updates"); + const [setupState, setSetupState] = useState(props.initialState); + const [stage1Error, setStage1Error] = useState(null); const [error, setError] = useState(null); - const [loadingDomains, setLoadingDomains] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [checking, setChecking] = useState(false); + + useEffect(() => { + if (props.open) { + setStage(props.initialState ? 2 : 1); + setSubdomain(props.initialState?.subdomain ?? ""); + setSenderLocalPart(props.initialState?.senderLocalPart ?? "updates"); + setSetupState(props.initialState); + setStage1Error(null); + setError(null); + setSubmitting(false); + setChecking(false); + } + }, [props.open, props.initialState]); - const refreshDomains = async () => { - setLoadingDomains(true); + const handleContinue = useCallback(async () => { + setStage1Error(null); try { - const result = await stackAdminApp.listManagedEmailDomains(); - setDomains(result); + await subdomainSchema.validate(subdomain); + await senderLocalPartSchema.validate(senderLocalPart); + } catch (e) { + setStage1Error(e instanceof yup.ValidationError ? e.message : "Invalid input"); + return; + } + setSubmitting(true); + try { + const result = await stackAdminApp.setupManagedEmailProvider({ + subdomain: subdomain.trim(), + senderLocalPart: senderLocalPart.trim(), + }); + const nextState: SetupState = { + domainId: result.domainId, + nameServerRecords: result.nameServerRecords, + subdomain: result.subdomain, + senderLocalPart: result.senderLocalPart, + status: result.status, + }; + setSetupState(nextState); + setStage(nextState.status === "verified" || nextState.status === "applied" ? 3 : 2); + props.onCompleted(); + } catch (e) { + setStage1Error(e instanceof Error ? e.message : "Failed to set up domain"); } finally { - setLoadingDomains(false); + setSubmitting(false); } - }; + }, [subdomain, senderLocalPart, stackAdminApp, props]); - return ( - { - setOpen(newOpen); - if (newOpen) { - runAsynchronouslyWithAlert(async () => { - await refreshDomains(); - }, { - onError: (err) => { - setError(err instanceof Error ? err.message : "Failed to load managed domains"); - }, - }); - } else { - setSetupState(null); - setDomains([]); - setError(null); - } - }} - title="Managed Email Setup" - formSchema={managedEmailSetupSchema} - defaultValues={{ subdomain: "", senderLocalPart: "updates" }} - okButton={setupState ? false : { label: "Start Setup" }} - cancelButton - onSubmit={async (values) => { - const setupResult = await stackAdminApp.setupManagedEmailProvider({ - subdomain: values.subdomain, - senderLocalPart: values.senderLocalPart, - }); - setSetupState({ - domainId: setupResult.domainId, - nameServerRecords: setupResult.nameServerRecords, - subdomain: setupResult.subdomain, - senderLocalPart: setupResult.senderLocalPart, - status: setupResult.status, + const handleCheck = useCallback(async () => { + if (!setupState) return; + setChecking(true); + setError(null); + try { + const result = await stackAdminApp.checkManagedEmailStatus({ + domainId: setupState.domainId, + subdomain: setupState.subdomain, + senderLocalPart: setupState.senderLocalPart, + }); + const next: SetupState = { ...setupState, status: result.status }; + setSetupState(next); + if (next.status === "verified" || next.status === "applied") { + setStage(3); + } else if (next.status === "failed") { + setError("Verification failed. Double-check your DNS records and try again."); + } else { + toast({ + title: "DNS not yet propagated", + description: "Give it a few more minutes — changes can take up to 48 hours.", }); - await refreshDomains(); - setError(null); - return "prevent-close" as const; - }} - render={(form) => ( - <> - {!setupState && ( + } + props.onCompleted(); + } catch (e) { + setError(e instanceof Error ? e.message : "Could not check verification"); + } finally { + setChecking(false); + } + }, [setupState, stackAdminApp, toast, props]); + + const handleApply = useCallback(async () => { + 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]); + + const steps = [ + { n: 1 as const, title: "Your domain" }, + { n: 2 as const, title: "DNS records" }, + { n: 3 as const, title: "Verify" }, + ]; + + return ( + + + Add managed domain + +
+
+
+
Add managed domain
+
Bring your own domain — takes about 5 minutes.
+
+
+
+ {steps.map((s, i) => { + const isDone = s.n < stage; + const isActive = s.n === stage; + const isLast = i === steps.length - 1; + const marker = ( +
+ {isDone ? : s.n} +
+ ); + const label = ( + + {s.title} + + ); + return ( +
+ {isLast ? ( + <> + {label} + {marker} + + ) : ( + <> + {marker} + {label} +
+ + )} +
+ ); + })} +
+
+ +
+ {stage === 1 && ( <> - - +
+ + { + setSubdomain(e.target.value); + setStage1Error(null); + }} + type="text" + placeholder="emails.example.com" + size="md" + /> + + Use a dedicated subdomain (e.g. emails.example.com), not your apex domain. + +
+
+ +
+ { + setSenderLocalPart(e.target.value); + setStage1Error(null); + }} + type="text" + size="md" + /> + + @{subdomain || "your-subdomain"} + +
+
+ {stage1Error && } )} - {setupState && ( - - Delegate your subdomain with these NS records - - Add these nameservers at your DNS provider for the managed subdomain you entered. -
- {setupState.nameServerRecords.map((record) => ( -
{record}
- ))} + + {stage === 2 && setupState && ( + <> +
+
+ Add these records to {setupState.subdomain}
- - +
+ Log into your DNS provider and create each row below. We'll detect them automatically. +
+
+ +
+
+
Type
+
Name
+
Content
+
+ {setupState.nameServerRecords.map((r, i) => ( +
+ NS + {setupState.subdomain} +
+ {r} + +
+
+ ))} +
+ + + DNS changes typically propagate within 10 minutes but can take up to 48 hours. + + + {error && } + )} - {setupState && ( -
+ + {stage === 3 && setupState && ( +
+
+ +
+
+
Domain verified
+
+ {setupState.senderLocalPart}@{setupState.subdomain} is ready to send. +
+
+ {error && } +
+ )} +
+ +
+ {stage === 2 && setupState ? ( + setStage(1)} + disabled={submitting || checking} + > + Back + + ) : stage === 3 ? ( + props.onOpenChange(false)}> + Close + + ) : ( + props.onOpenChange(false)}> + Cancel + + )} +
+ {stage === 2 && ( + runAsynchronouslyWithAlert(handleCheck)} + > + Check verification + + )} + {stage === 1 && ( runAsynchronouslyWithAlert(async () => { - const result = await stackAdminApp.checkManagedEmailStatus({ - domainId: setupState.domainId, - subdomain: setupState.subdomain, - senderLocalPart: setupState.senderLocalPart, - }); - setSetupState({ ...setupState, status: result.status }); - await refreshDomains(); - })} + loading={submitting} + onClick={() => runAsynchronouslyWithAlert(handleContinue)} > - - Refresh Status + Continue + )} + {stage === 3 && ( runAsynchronouslyWithAlert(async () => { - await stackAdminApp.applyManagedEmailProvider({ - domainId: setupState.domainId, - }); - setOpen(false); - })} + loading={submitting} + onClick={() => runAsynchronouslyWithAlert(handleApply)} > - Use This Domain + Use this domain -
- )} - {(() => { - const visibleDomains = setupState ? domains.filter((d) => d.domainId === setupState.domainId) : domains; - return ( -
- Tracked managed domains - {loadingDomains ? ( - Loading managed domains... - ) : visibleDomains.length === 0 ? ( - No managed domains tracked yet. - ) : ( - visibleDomains.map((domain) => ( - - {domain.senderLocalPart}@{domain.subdomain} - - Status: {(MANAGED_DOMAIN_STATUS_LABELS as Record)[domain.status] ?? domain.status} - runAsynchronouslyWithAlert(async () => { - await stackAdminApp.applyManagedEmailProvider({ - domainId: domain.domainId, - }); - await refreshDomains(); - })} - > - Use This Domain - - - - )) - )} -
- ); - })()} - {error && } - - )} - /> + )} +
+
+ +
); } @@ -366,33 +576,68 @@ export function DomainSettings() { const [serverType, setServerType] = useState(savedServerType); const [formValues, setFormValues] = useState>(savedValues); - const [configExpanded, setConfigExpanded] = useState(false); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); + const [domains, setDomains] = useState([]); + const [loadingDomains, setLoadingDomains] = useState(false); + const [dialog, setDialog] = useState<{ initialState: SetupState | null } | null>(null); + + const refreshDomains = useCallback(async () => { + setLoadingDomains(true); + try { + const result = await stackAdminApp.listManagedEmailDomains(); + setDomains(result); + } finally { + setLoadingDomains(false); + } + }, [stackAdminApp]); + + useEffect(() => { + if (serverType === "managed") { + runAsynchronouslyWithAlert(refreshDomains); + } + }, [serverType, refreshDomains]); + const isShared = serverType === "shared"; - const visibleFields = VISIBLE_FIELDS[serverType]; - const configFields = CONFIG_FIELDS[serverType]; - const hasConfigFields = configFields.length > 0; + + const visibleSenderFields = serverType === "resend" || serverType === "standard"; + const configFields = useMemo(() => { + if (serverType === "resend") { + return [{ label: "Resend API Key", key: "password", type: "password" as const }]; + } + if (serverType === "standard") { + return [ + { label: "Host", key: "host", type: "text" as const }, + { label: "Port", key: "port", type: "number" as const }, + { label: "Username", key: "username", type: "text" as const }, + { label: "Password", key: "password", type: "password" as const }, + ]; + } + return []; + }, [serverType]); const isDirty = useMemo(() => { if (serverType !== savedServerType) return true; - for (const field of [...visibleFields, ...configFields]) { - if ((formValues[field.key] || "") !== (savedValues[field.key] || "")) return true; + const keys = new Set(); + if (visibleSenderFields) { + keys.add("senderEmail"); + keys.add("senderName"); + } + for (const f of configFields) keys.add(f.key); + for (const k of keys) { + if ((formValues[k] || "") !== (savedValues[k] || "")) return true; } return false; - }, [serverType, savedServerType, formValues, savedValues, visibleFields, configFields]); + }, [serverType, savedServerType, formValues, savedValues, visibleSenderFields, configFields]); const updateField = useCallback((key: string, value: string) => { setFormValues(prev => ({ ...prev, [key]: value })); setSaveError(null); }, []); - const handleServerTypeChange = useCallback((newType: ServerType) => { + const handleSelectProvider = useCallback((newType: ServerType) => { setServerType(newType); - if (CONFIG_FIELDS[newType].length > 0) { - setConfigExpanded(true); - } setSaveError(null); }, []); @@ -415,12 +660,6 @@ export function DomainSettings() { pushable: false, }); toast({ title: "Email server updated", variant: "success" }); - } else if (serverType === "managed") { - toast({ - title: "Email server unchanged", - description: "Managed email configuration is controlled through the managed domain setup.", - variant: "success", - }); } else { const requireField = (key: string, label: string): string => { const val = formValues[key]; @@ -482,6 +721,8 @@ export function DomainSettings() { }); toast({ title: "Email server updated", variant: "success" }); } + } catch (e) { + setSaveError(e instanceof Error ? e.message : "Could not save changes"); } finally { setSaving(false); } @@ -499,200 +740,281 @@ export function DomainSettings() { ); } - const emailFormatError = !isShared && formValues.senderEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formValues.senderEmail) + const emailFormatError = visibleSenderFields && formValues.senderEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formValues.senderEmail) ? "Invalid email format" : null; - const missingRequiredFields = !isShared ? [ - ...visibleFields.filter(f => !(formValues[f.key] || "").trim()), - ...configFields.filter(f => !(formValues[f.key] || "").trim()), + const missingRequiredFields = visibleSenderFields ? [ + ...(!(formValues.senderEmail || "").trim() ? ["Sender Email"] : []), + ...(!(formValues.senderName || "").trim() ? ["Sender Name"] : []), + ...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 + : null; return ( - -
- {/* Header */} -
-
- -
- - Email Server - -
- - {/* Sender identity + server type -- 3-column grid */} -
-
- - handleServerTypeChange(v as ServerType)} - options={[ - { value: "shared", label: SERVER_TYPE_LABELS.shared }, - { value: "managed", label: SERVER_TYPE_LABELS.managed }, - { value: "resend", label: SERVER_TYPE_LABELS.resend }, - { value: "standard", label: SERVER_TYPE_LABELS.standard }, - ]} - size="md" - /> -
-
- - {isShared ? ( - - noreply@stackframe.co - - ) : serverType === "managed" ? ( - - - {formValues.senderEmail || "Not configured"} - - - ) : ( - <> - updateField("senderEmail", e.target.value)} - className={cn(emailFormatError && "border-destructive")} - type="email" - placeholder="you@example.com" - size="md" - /> - {emailFormatError && ( - {emailFormatError} - )} - - )} -
-
- - {isShared || serverType === "managed" ? ( - - {project.displayName} - - ) : ( - updateField("senderName", e.target.value)} - type="text" - placeholder="Your App Name" - size="md" - /> - )} -
-
- - {/* Managed domain info + setup trigger */} - {serverType === "managed" && ( -
- {savedServerType === "managed" && formValues.managedSubdomain && ( -
-
- - {formValues.managedSubdomain} -
-
- - {formValues.senderEmail} -
+ <> + +
+
+
+
+
+ Email Server +
+ {!isShared && serverType !== "managed" && !isDirty && ( + + + Send test email + + } + /> )} - - - {savedServerType === "managed" ? "Manage Domain" : "Set Up Managed Domain"} - - } - /> -
- )} - - {/* Send Test Email -- prominent, centered */} - {!isShared && serverType !== "managed" && !isDirty && ( -
- - - Send Test Email - - } - />
- )} - {/* Config expand toggle + expanded fields */} - {hasConfigFields && ( -
-
- +
+ {PROVIDERS.map((p) => { + const isSelected = serverType === p.value; + const isSaved = savedServerType === p.value; + const isDraft = isSelected && !isSaved; + const Icon = p.icon; + return ( - - - {configExpanded ? "Server configuration" : "Show server configuration"} - + ); + })} +
+ + {isDirty && serverType !== "managed" && ( +
+
+ + + Unsaved changes — previewing{" "} + {PROVIDERS.find((p) => p.value === serverType)?.label}. + Changes don't take effect until you save. + +
+
+ + Discard + + runAsynchronouslyWithAlert(handleSave)} + > + Save changes + +
+ )} - {configExpanded && ( + {isShared && ( + + )} + + {serverType === "managed" && ( +
+
+
+
Tracked managed domains
+
Domains you own that we sign & deliver from.
+
+ setDialog({ initialState: null })}> + Add domain + +
+ + {loadingDomains && domains.length === 0 ? ( +
+ + Loading managed domains… +
+ ) : domains.length === 0 ? ( +
+ +
No managed domains yet
+
Add your first domain to start sending from a custom sender.
+
+ ) : ( +
+ {domains.map((d) => { + const isInUse = d.domainId === activeManagedDomainId; + const isReadyButUnused = !isInUse && (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]; + return ( +
+
+ +
+
+ {d.senderLocalPart}@{d.subdomain} +
+
+ {isInUse ? "In use for this project" : "Not in use"} +
+
+
+
+ + {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(); + })} + > + Use this domain + + )} + {isPending && ( + setDialog({ initialState: { + domainId: d.domainId, + subdomain: d.subdomain, + senderLocalPart: d.senderLocalPart, + nameServerRecords: d.nameServerRecords, + status: d.status, + } })} + > + View DNS + + )} +
+
+ ); + })} +
+ )} +
+ )} + + {(serverType === "resend" || serverType === "standard") && ( +
- {configFields.map((field) => { - const isEmpty = !(formValues[field.key] || "").trim(); - return ( +
+ + updateField("senderEmail", e.target.value)} + className={cn(emailFormatError && "border-destructive")} + type="email" + placeholder="you@example.com" + size="md" + /> + {emailFormatError && ( + {emailFormatError} + )} +
+
+ + updateField("senderName", e.target.value)} + type="text" + placeholder="Your App Name" + size="md" + /> +
+
+ + {serverType === "resend" && ( +
+ + updateField("password", e.target.value)} + type="password" + placeholder="re_..." + size="md" + /> +
+ )} + + {serverType === "standard" && ( +
+ {configFields.map((field) => (
updateField(field.key, e.target.value)} - className={cn(isDirty && isEmpty && "border-destructive")} type={field.type} size="md" /> - {isDirty && isEmpty && ( - {field.label} is required - )}
- ); - })} -
- )} -
- )} + ))} +
+ )} - {/* Save error */} - {saveError && ( - - )} + {saveError && ( + + )} +
+ )} +
+
- {/* Save / Cancel -- only when dirty, not for managed (config is set through setup dialog) */} - {isDirty && serverType !== "managed" && ( -
- - Cancel - - runAsynchronouslyWithAlert(handleSave)} - > - Save - -
- )} -
- + { if (!o) setDialog(null); }} + initialState={dialog?.initialState ?? null} + onCompleted={() => { runAsynchronouslyWithAlert(refreshDomains); }} + /> + ); } 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 62c5da05bf..c248c1b0b7 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 @@ -1,3 +1,4 @@ +import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { describe } from "vitest"; import { it } from "../../../../../helpers"; import { Project, niceBackendFetch } from "../../../../backend-helpers"; @@ -48,7 +49,12 @@ describe("managed email onboarding internal endpoints", () => { expect(setupResponse.status).toBe(200); expect(setupResponse.body.domain_id).toBeDefined(); - expect(setupResponse.body.status).toBe("verified"); + expect(setupResponse.body.status).toBe("pending_verification"); + + // Mock onboarding asynchronously flips status to "verified" ~1s after setup + // (mirroring the real Resend webhook flow). Wait for the transition before + // asserting verified state. + await wait(1500); const listResponse = await niceBackendFetch("/api/v1/internal/emails/managed-onboarding/list", { method: "GET",