diff --git a/apps/backend/.env.development b/apps/backend/.env.development index f296d46e63..8a87791116 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -95,3 +95,10 @@ STACK_CLICKHOUSE_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}36 STACK_CLICKHOUSE_ADMIN_USER=stackframe STACK_CLICKHOUSE_ADMIN_PASSWORD=PASSWORD-PLACEHOLDER--9gKyMxJeMx STACK_CLICKHOUSE_EXTERNAL_PASSWORD=PASSWORD-PLACEHOLDER--EZeHscBMzE + +# Managed emails +STACK_RESEND_API_KEY=mock_resend_api_key +STACK_RESEND_WEBHOOK_SECRET=mock_resend_webhook_secret +STACK_DNSIMPLE_API_TOKEN=mock_dnsimple_api_token +STACK_DNSIMPLE_ACCOUNT_ID=mock_dnsimple_account_id +STACK_DNSIMPLE_API_BASE_URL=https://api.dnsimple.com/v2 diff --git a/apps/backend/prisma/migrations/20260224000000_managed_email_domains/migration.sql b/apps/backend/prisma/migrations/20260224000000_managed_email_domains/migration.sql new file mode 100644 index 0000000000..3060d1f7ca --- /dev/null +++ b/apps/backend/prisma/migrations/20260224000000_managed_email_domains/migration.sql @@ -0,0 +1,28 @@ +CREATE TYPE "ManagedEmailDomainStatus" AS ENUM ('PENDING_DNS', 'PENDING_VERIFICATION', 'VERIFIED', 'APPLIED', 'FAILED'); + +CREATE TABLE "ManagedEmailDomain" ( + "id" UUID NOT NULL, + "tenancyId" UUID NOT NULL, + "projectId" TEXT NOT NULL, + "branchId" TEXT NOT NULL, + "subdomain" TEXT NOT NULL, + "senderLocalPart" TEXT NOT NULL, + "resendDomainId" TEXT NOT NULL, + "nameServerRecords" JSONB NOT NULL, + "status" "ManagedEmailDomainStatus" NOT NULL DEFAULT 'PENDING_VERIFICATION', + "providerStatusRaw" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "lastError" TEXT, + "verifiedAt" TIMESTAMP(3), + "appliedAt" TIMESTAMP(3), + "lastWebhookAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ManagedEmailDomain_pkey" PRIMARY KEY ("id"), + CONSTRAINT "ManagedEmailDomain_tenancyId_fkey" FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX "ManagedEmailDomain_resendDomainId_key" ON "ManagedEmailDomain"("resendDomainId"); +CREATE UNIQUE INDEX "ManagedEmailDomain_tenancyId_subdomain_key" ON "ManagedEmailDomain"("tenancyId", "subdomain"); +CREATE INDEX "ManagedEmailDomain_tenancy_status_active_idx" ON "ManagedEmailDomain"("tenancyId", "status", "isActive"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 0ce2ed3914..b512a3995b 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -62,11 +62,49 @@ model Tenancy { emailOutboxes EmailOutbox[] sessionReplays SessionReplay[] sessionReplayChunks SessionReplayChunk[] + managedEmailDomains ManagedEmailDomain[] @@unique([projectId, branchId, organizationId]) @@unique([projectId, branchId, hasNoOrganization]) } +enum ManagedEmailDomainStatus { + PENDING_DNS + PENDING_VERIFICATION + VERIFIED + APPLIED + FAILED +} + +model ManagedEmailDomain { + id String @id @default(uuid()) @db.Uuid + + tenancyId String @db.Uuid + tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade) + + projectId String + branchId String + + subdomain String + senderLocalPart String + resendDomainId String @unique + nameServerRecords Json + + status ManagedEmailDomainStatus @default(PENDING_VERIFICATION) + providerStatusRaw String? + isActive Boolean @default(true) + lastError String? + verifiedAt DateTime? + appliedAt DateTime? + lastWebhookAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([tenancyId, subdomain]) + @@index([tenancyId, status, isActive], map: "ManagedEmailDomain_tenancy_status_active_idx") +} + model BranchConfigOverride { projectId String branchId String diff --git a/apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx b/apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx new file mode 100644 index 0000000000..ba43ca90f3 --- /dev/null +++ b/apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx @@ -0,0 +1,100 @@ +import { processResendDomainWebhookEvent } from "@/lib/managed-email-onboarding"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { yupBoolean, yupMixed, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { Result } from "@stackframe/stack-shared/dist/utils/results"; +import { Webhook } from "svix"; + +function decodeBody(bodyBuffer: ArrayBuffer) { + return new TextDecoder().decode(bodyBuffer); +} + +function ensureResendWebhookSignature(headers: Record, bodyBuffer: ArrayBuffer) { + const webhookSecret = getEnvVariable("STACK_RESEND_WEBHOOK_SECRET"); + const svixId = headers["svix-id"]?.[0] ?? null; + const svixTimestamp = headers["svix-timestamp"]?.[0] ?? null; + const svixSignature = headers["svix-signature"]?.[0] ?? null; + if (svixId == null || svixTimestamp == null || svixSignature == null) { + throw new StatusError(400, "Missing Svix signature headers for Resend webhook"); + } + + const verifier = new Webhook(webhookSecret); + const result = Result.fromThrowing(() => verifier.verify(decodeBody(bodyBuffer), { + "svix-id": svixId, + "svix-timestamp": svixTimestamp, + "svix-signature": svixSignature, + })); + if (result.status === "error") { + throw new StatusError(400, "Invalid Resend webhook signature"); + } +} + +type ResendDomainWebhookPayload = { + type?: string, + data?: { + id?: string, + status?: string, + error?: string, + }, +}; + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + headers: yupObject({ + "svix-id": yupTuple([yupString().defined()]).defined(), + "svix-timestamp": yupTuple([yupString().defined()]).defined(), + "svix-signature": yupTuple([yupString().defined()]).defined(), + }).defined(), + body: yupMixed().optional(), + method: yupString().oneOf(["POST"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + received: yupBoolean().defined(), + }).defined(), + }), + handler: async (req, fullReq) => { + ensureResendWebhookSignature(req.headers, fullReq.bodyBuffer); + + const payloadResult = Result.fromThrowing(() => JSON.parse(decodeBody(fullReq.bodyBuffer)) as ResendDomainWebhookPayload); + if (payloadResult.status === "error") { + throw new StatusError(400, "Invalid JSON payload in Resend webhook"); + } + if (payloadResult.data.type !== "domain.updated") { + return { + statusCode: 200, + bodyType: "json", + body: { received: true }, + }; + } + const payload = payloadResult.data; + + const domainId = payload.data?.id; + const providerStatusRaw = payload.data?.status; + if (domainId == null || providerStatusRaw == null) { + throw new StackAssertionError("Resend webhook payload missing required domain fields", { + payload, + }); + } + + await processResendDomainWebhookEvent({ + domainId, + providerStatusRaw, + errorMessage: payload.data?.error, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + received: true, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/emails/managed-onboarding/apply/route.tsx b/apps/backend/src/app/api/latest/internal/emails/managed-onboarding/apply/route.tsx new file mode 100644 index 0000000000..cd939d2791 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/emails/managed-onboarding/apply/route.tsx @@ -0,0 +1,40 @@ +import { applyManagedEmailProvider } 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({ + 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(["applied"]).defined(), + }).defined(), + }), + handler: async ({ auth, body }) => { + const result = await applyManagedEmailProvider({ + tenancy: auth.tenancy, + domainId: body.domain_id, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + status: result.status, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/emails/managed-onboarding/check/route.tsx b/apps/backend/src/app/api/latest/internal/emails/managed-onboarding/check/route.tsx new file mode 100644 index 0000000000..8d6d1fa9f3 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/emails/managed-onboarding/check/route.tsx @@ -0,0 +1,44 @@ +import { checkManagedEmailProviderStatus } 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({ + domain_id: yupString().defined(), + subdomain: yupString().defined(), + sender_local_part: 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(["pending_dns", "pending_verification", "verified", "applied", "failed"]).defined(), + }).defined(), + }), + handler: async ({ auth, body }) => { + const checkResult = await checkManagedEmailProviderStatus({ + tenancy: auth.tenancy, + domainId: body.domain_id, + subdomain: body.subdomain, + senderLocalPart: body.sender_local_part, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + status: checkResult.status, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/emails/managed-onboarding/list/route.tsx b/apps/backend/src/app/api/latest/internal/emails/managed-onboarding/list/route.tsx new file mode 100644 index 0000000000..ecadd479b0 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/emails/managed-onboarding/list/route.tsx @@ -0,0 +1,48 @@ +import { listManagedEmailProviderDomains } from "@/lib/managed-email-onboarding"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; + +export const GET = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + method: yupString().oneOf(["GET"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + items: yupArray(yupObject({ + domain_id: yupString().defined(), + subdomain: yupString().defined(), + sender_local_part: yupString().defined(), + status: yupString().oneOf(["pending_dns", "pending_verification", "verified", "applied", "failed"]).defined(), + name_server_records: yupArray(yupString().defined()).defined(), + }).defined()).defined(), + }).defined(), + }), + handler: async ({ auth }) => { + const items = await listManagedEmailProviderDomains({ + tenancy: auth.tenancy, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + items: items.map((item) => ({ + domain_id: item.domainId, + subdomain: item.subdomain, + sender_local_part: item.senderLocalPart, + status: item.status, + name_server_records: item.nameServerRecords, + })), + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/emails/managed-onboarding/setup/route.tsx b/apps/backend/src/app/api/latest/internal/emails/managed-onboarding/setup/route.tsx new file mode 100644 index 0000000000..4891fc81df --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/emails/managed-onboarding/setup/route.tsx @@ -0,0 +1,50 @@ +import { setupManagedEmailProvider } from "@/lib/managed-email-onboarding"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, adminAuthTypeSchema, yupArray, 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({ + subdomain: yupString().defined(), + sender_local_part: yupString().defined(), + }).defined(), + method: yupString().oneOf(["POST"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + domain_id: yupString().defined(), + subdomain: yupString().defined(), + sender_local_part: yupString().defined(), + name_server_records: yupArray(yupString().defined()).defined(), + status: yupString().oneOf(["pending_dns", "pending_verification", "verified", "applied", "failed"]).defined(), + }).defined(), + }), + handler: async ({ auth, body }) => { + const setupResult = await setupManagedEmailProvider({ + subdomain: body.subdomain, + senderLocalPart: body.sender_local_part, + tenancy: auth.tenancy, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + domain_id: setupResult.domainId, + subdomain: setupResult.subdomain, + sender_local_part: setupResult.senderLocalPart, + name_server_records: setupResult.nameServerRecords, + status: setupResult.status, + }, + }; + }, +}); diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index f7a60e1941..08d76ea0e9 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -1090,6 +1090,16 @@ export const renderedOrganizationConfigToProjectCrud = (renderedConfig: Complete email_config: renderedConfig.emails.server.isShared ? { type: 'shared', + } : renderedConfig.emails.server.provider === "managed" ? { + type: 'standard', + host: "smtp.resend.com", + port: 465, + username: "resend", + password: renderedConfig.emails.server.password, + sender_name: renderedConfig.emails.server.senderName, + sender_email: renderedConfig.emails.server.managedSubdomain && renderedConfig.emails.server.managedSenderLocalPart + ? `${renderedConfig.emails.server.managedSenderLocalPart}@${renderedConfig.emails.server.managedSubdomain}` + : renderedConfig.emails.server.senderEmail, } : { type: 'standard', host: renderedConfig.emails.server.host, diff --git a/apps/backend/src/lib/emails.tsx b/apps/backend/src/lib/emails.tsx index 382093f929..bf54982aa0 100644 --- a/apps/backend/src/lib/emails.tsx +++ b/apps/backend/src/lib/emails.tsx @@ -104,6 +104,25 @@ export async function getEmailConfig(tenancy: Tenancy): Promise typeof item !== "string")) { + throw new StackAssertionError("ManagedEmailDomain.nameServerRecords stored invalid JSON", { + nameServerRecords: value, + }); + } + return value; +} + +function mapRow(row: ManagedEmailDomainRow): ManagedEmailDomain { + return { + id: row.id, + tenancyId: row.tenancyId, + projectId: row.projectId, + branchId: row.branchId, + subdomain: row.subdomain, + senderLocalPart: row.senderLocalPart, + resendDomainId: row.resendDomainId, + nameServerRecords: parseNameServerRecords(row.nameServerRecords), + status: dbStatusToStatus(row.status), + providerStatusRaw: row.providerStatusRaw, + isActive: row.isActive, + lastError: row.lastError, + verifiedAt: row.verifiedAt, + appliedAt: row.appliedAt, + lastWebhookAt: row.lastWebhookAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export async function getManagedEmailDomainByTenancyAndSubdomain(options: { + tenancyId: string, + subdomain: string, +}): Promise { + const rows = await globalPrismaClient.$queryRaw(Prisma.sql` + SELECT * + FROM "ManagedEmailDomain" + WHERE "tenancyId" = ${options.tenancyId} + AND "subdomain" = ${options.subdomain} + LIMIT 1 + `); + if (rows.length === 0) { + return null; + } + return mapRow(rows[0]!); +} + +export async function getManagedEmailDomainByResendDomainId(resendDomainId: string): Promise { + const rows = await globalPrismaClient.$queryRaw(Prisma.sql` + SELECT * + FROM "ManagedEmailDomain" + WHERE "resendDomainId" = ${resendDomainId} + LIMIT 1 + `); + if (rows.length === 0) { + return null; + } + return mapRow(rows[0]!); +} + +export async function createManagedEmailDomain(options: { + tenancyId: string, + projectId: string, + branchId: string, + subdomain: string, + senderLocalPart: string, + resendDomainId: string, + nameServerRecords: string[], + status: ManagedEmailDomainStatus, +}): Promise { + const row = await globalPrismaClient.managedEmailDomain.create({ + data: { + tenancyId: options.tenancyId, + projectId: options.projectId, + branchId: options.branchId, + subdomain: options.subdomain, + senderLocalPart: options.senderLocalPart, + resendDomainId: options.resendDomainId, + nameServerRecords: options.nameServerRecords, + status: statusToDbStatus(options.status), + isActive: true + } + }); + return mapRow(row); +} + +export async function updateManagedEmailDomainWebhookStatus(options: { + resendDomainId: string, + providerStatusRaw: string, + status: ManagedEmailDomainStatus, + lastError: string | null, +}): Promise { + const verifiedAt = options.status === "verified" ? Prisma.sql`CURRENT_TIMESTAMP` : Prisma.sql`"verifiedAt"`; + const rows = await globalPrismaClient.$queryRaw(Prisma.sql` + UPDATE "ManagedEmailDomain" + SET + "providerStatusRaw" = ${options.providerStatusRaw}, + "status" = ${statusToDbStatus(options.status)}::"ManagedEmailDomainStatus", + "lastError" = ${options.lastError}, + "lastWebhookAt" = CURRENT_TIMESTAMP, + "verifiedAt" = ${verifiedAt}, + "updatedAt" = CURRENT_TIMESTAMP + WHERE "resendDomainId" = ${options.resendDomainId} + AND "isActive" = true + RETURNING * + `); + if (rows.length === 0) { + return null; + } + return mapRow(rows[0]!); +} + +export async function markManagedEmailDomainApplied(id: string): Promise { + const rows = await globalPrismaClient.$queryRaw(Prisma.sql` + UPDATE "ManagedEmailDomain" + SET + "status" = 'APPLIED'::"ManagedEmailDomainStatus", + "appliedAt" = CURRENT_TIMESTAMP, + "updatedAt" = CURRENT_TIMESTAMP + WHERE "id" = ${id} + RETURNING * + `); + if (rows.length === 0) { + throw new StackAssertionError("Managed email domain row missing while applying", { + managedEmailDomainId: id, + }); + } + return mapRow(rows[0]!); +} + +export async function listManagedEmailDomainsForTenancy(tenancyId: string): Promise { + const rows = await globalPrismaClient.$queryRaw(Prisma.sql` + SELECT * + FROM "ManagedEmailDomain" + WHERE "tenancyId" = ${tenancyId} + ORDER BY "isActive" DESC, "updatedAt" DESC + `); + return rows.map(mapRow); +} diff --git a/apps/backend/src/lib/managed-email-onboarding.tsx b/apps/backend/src/lib/managed-email-onboarding.tsx new file mode 100644 index 0000000000..c3ce3346d7 --- /dev/null +++ b/apps/backend/src/lib/managed-email-onboarding.tsx @@ -0,0 +1,679 @@ +import { overrideEnvironmentConfigOverride } from "@/lib/config"; +import { + ManagedEmailDomain, + ManagedEmailDomainStatus, + createManagedEmailDomain, + getManagedEmailDomainByResendDomainId, + getManagedEmailDomainByTenancyAndSubdomain, + listManagedEmailDomainsForTenancy, + markManagedEmailDomainApplied, + updateManagedEmailDomainWebhookStatus, +} from "@/lib/managed-email-domains"; +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"; + +type ResendDomainRecord = { + record: string, + name: string, + type: string, + value: string, + status: string, + priority?: number, +}; + +type ResendDomain = { + id: string, + name: string, + status?: "not_started" | "pending" | "verified" | "partially_verified" | "partially_failed" | "failed" | "temporary_failure", + records?: ResendDomainRecord[], +}; + +export type ManagedEmailSetupResult = { + domainId: string, + subdomain: string, + senderLocalPart: string, + nameServerRecords: string[], + status: ManagedEmailDomainStatus, +}; + +export type ManagedEmailCheckResult = { + status: ManagedEmailDomainStatus, +}; + +export type ManagedEmailApplyResult = { + status: "applied", +}; + +export type ManagedEmailListItem = { + domainId: string, + subdomain: string, + senderLocalPart: string, + status: ManagedEmailDomainStatus, + nameServerRecords: string[], + verifiedAt: number | null, + appliedAt: number | null, +}; + +function shouldUseMockManagedEmailOnboarding() { + const nodeEnvironment = getNodeEnvironment(); + if (nodeEnvironment === "development" || nodeEnvironment === "test") { + const resendApiKey = getEnvVariable("STACK_RESEND_API_KEY", ""); + if (resendApiKey === "mock_resend_api_key") { + return true; + } + } + + return false; +} + +function assertValidManagedSubdomain(subdomain: string) { + if (!/^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9-]{2,63}$/.test(subdomain)) { + throw new StatusError(400, "subdomain must be a fully-qualified domain name like mail.example.com"); + } +} + +function assertValidManagedSenderLocalPart(senderLocalPart: string) { + if (!/^[a-zA-Z0-9._%+-]+$/.test(senderLocalPart)) { + throw new StatusError(400, "sender_local_part is invalid"); + } +} + +function getManagedSenderEmail(subdomain: string, senderLocalPart: string) { + return `${senderLocalPart}@${subdomain}`; +} + +function normalizeDomainName(name: string) { + return name.trim().toLowerCase().replace(/\.+$/, ""); +} + +function normalizeRecordName(name: string, zoneName: string) { + const normalizedName = normalizeDomainName(name); + const normalizedZoneName = normalizeDomainName(zoneName); + if (normalizedName === "@") { + return normalizedZoneName; + } + if (normalizedName === normalizedZoneName) { + return normalizedZoneName; + } + if (normalizedName.endsWith(`.${normalizedZoneName}`)) { + return normalizedName; + } + + const zoneLabels = normalizedZoneName.split("."); + const zoneSubdomainLabel = zoneLabels[0]; + if (zoneSubdomainLabel && normalizedName.endsWith(`.${zoneSubdomainLabel}`)) { + const recordWithoutZoneSubdomainLabel = normalizedName.slice(0, -(zoneSubdomainLabel.length + 1)); + if (recordWithoutZoneSubdomainLabel.length > 0) { + return `${recordWithoutZoneSubdomainLabel}.${normalizedZoneName}`; + } + } + + return `${normalizedName}.${normalizedZoneName}`; +} + +function normalizeRecordContent(content: string) { + return content.trim().replace(/\.+$/, ""); +} + +async function parseJsonOrThrow(response: Response, errorContext: string): Promise { + if (!response.ok) { + const responseBody = await response.text(); + throw new StackAssertionError(errorContext, { + status: response.status, + responseBody, + }); + } + return await response.json() as T; +} + +type DnsimpleResponse = { + data?: T, +}; + +type DnsimpleZone = { + id: string | number, + name: string, +}; + +type DnsimpleDomain = { + id: string | number, + name: string, +}; + +type DnsimpleDnsRecord = { + id: string | number, + type: string, + name: string, + content: string, + priority?: number | null, + prio?: number | null, +}; + +async function parseDnsimpleJsonOrThrow(response: Response, errorContext: string): Promise { + const body = await parseJsonOrThrow>(response, errorContext); + if (!body.data) { + throw new StackAssertionError(errorContext, { + dnsimpleResponseBody: body, + }); + } + return body.data; +} + +function getDnsimpleBaseUrl() { + return getEnvVariable("STACK_DNSIMPLE_API_BASE_URL", "https://api.dnsimple.com/v2"); +} + +function getDnsimpleHeaders() { + return { + "Authorization": `Bearer ${getEnvVariable("STACK_DNSIMPLE_API_TOKEN")}`, + "Content-Type": "application/json", + "Accept": "application/json", + }; +} + +function getDnsimpleAccountId() { + return getEnvVariable("STACK_DNSIMPLE_ACCOUNT_ID"); +} + +async function listDnsimpleZones(subdomain: string): Promise { + const dnsimpleBaseUrl = getDnsimpleBaseUrl(); + const dnsimpleAccountId = getDnsimpleAccountId(); + const response = await fetch(`${dnsimpleBaseUrl}/${encodeURIComponent(dnsimpleAccountId)}/zones?name_like=${encodeURIComponent(subdomain)}&page=1&per_page=100`, { + method: "GET", + headers: getDnsimpleHeaders(), + }); + const zones = await parseDnsimpleJsonOrThrow( + response, + "Failed to list DNSimple zones for managed email onboarding", + ); + return zones.filter((zone) => normalizeDomainName(zone.name) === normalizeDomainName(subdomain)); +} + +async function getDnsimpleZoneByName(zoneName: string): Promise { + const dnsimpleBaseUrl = getDnsimpleBaseUrl(); + const dnsimpleAccountId = getDnsimpleAccountId(); + const response = await fetch(`${dnsimpleBaseUrl}/${encodeURIComponent(dnsimpleAccountId)}/zones/${encodeURIComponent(zoneName)}`, { + method: "GET", + headers: getDnsimpleHeaders(), + }); + return await parseDnsimpleJsonOrThrow( + response, + "Failed to fetch DNSimple zone details for managed email onboarding", + ); +} + +async function createDnsimpleZone(subdomain: string): Promise { + const dnsimpleBaseUrl = getDnsimpleBaseUrl(); + const dnsimpleAccountId = getDnsimpleAccountId(); + const response = await fetch(`${dnsimpleBaseUrl}/${encodeURIComponent(dnsimpleAccountId)}/domains`, { + method: "POST", + headers: getDnsimpleHeaders(), + body: JSON.stringify({ + name: normalizeDomainName(subdomain), + }), + }); + const domain = await parseDnsimpleJsonOrThrow( + response, + "Failed to create DNSimple domain for managed email onboarding", + ); + return { + id: domain.id, + name: domain.name, + }; +} + +async function createOrReuseDnsimpleZone(subdomain: string): Promise { + const existingZones = await listDnsimpleZones(subdomain); + if (existingZones.length > 1) { + throw new StackAssertionError("Multiple DNSimple zones found for managed email onboarding subdomain", { + subdomain, + zoneIds: existingZones.map((zone) => `${zone.id}`), + }); + } + const zone = existingZones[0] ?? await createDnsimpleZone(subdomain); + return await getDnsimpleZoneByName(zone.name); +} + +async function getDnsimpleZoneNameServers(zoneName: string): Promise { + const dnsimpleBaseUrl = getDnsimpleBaseUrl(); + const dnsimpleAccountId = getDnsimpleAccountId(); + const response = await fetch(`${dnsimpleBaseUrl}/${encodeURIComponent(dnsimpleAccountId)}/zones/${encodeURIComponent(zoneName)}/file`, { + method: "GET", + headers: getDnsimpleHeaders(), + }); + const zoneFile = await parseDnsimpleJsonOrThrow<{ zone?: string }>( + response, + "Failed to fetch DNSimple zone file for managed email onboarding", + ); + const rawZoneFile = zoneFile.zone; + if (!rawZoneFile) { + throw new StackAssertionError("DNSimple zone file response did not include zone contents", { + zoneName, + zoneFile, + }); + } + + const nameServerSet = new Set(); + for (const line of rawZoneFile.split("\n")) { + const match = line.match(/\sIN\s+NS\s+([^\s]+)\s*$/i); + if (!match) { + continue; + } + const nameServer = normalizeRecordContent(match[1]); + if (nameServer.length > 0) { + nameServerSet.add(nameServer); + } + } + return [...nameServerSet]; +} + +async function listDnsimpleDnsRecords(zoneName: string): Promise { + const dnsimpleBaseUrl = getDnsimpleBaseUrl(); + const dnsimpleAccountId = getDnsimpleAccountId(); + const response = await fetch(`${dnsimpleBaseUrl}/${encodeURIComponent(dnsimpleAccountId)}/zones/${encodeURIComponent(zoneName)}/records?page=1&per_page=100`, { + method: "GET", + headers: getDnsimpleHeaders(), + }); + return await parseDnsimpleJsonOrThrow( + response, + "Failed to list DNSimple DNS records for managed email onboarding", + ); +} + +function toDnsimpleRecordName(recordName: string, zoneName: string) { + const normalizedRecordName = normalizeDomainName(recordName); + const normalizedZoneName = normalizeDomainName(zoneName); + if (normalizedRecordName === normalizedZoneName) { + return ""; + } + if (normalizedRecordName.endsWith(`.${normalizedZoneName}`)) { + return normalizedRecordName.slice(0, -(normalizedZoneName.length + 1)); + } + throw new StackAssertionError("DNS record name is not inside zone", { + recordName, + zoneName, + }); +} + +async function createDnsimpleDnsRecord(zoneName: string, record: { + type: string, + name: string, + content: string, + priority?: number, +}) { + const dnsimpleBaseUrl = getDnsimpleBaseUrl(); + const dnsimpleAccountId = getDnsimpleAccountId(); + const response = await fetch(`${dnsimpleBaseUrl}/${encodeURIComponent(dnsimpleAccountId)}/zones/${encodeURIComponent(zoneName)}/records`, { + method: "POST", + headers: getDnsimpleHeaders(), + body: JSON.stringify({ + type: record.type, + name: toDnsimpleRecordName(record.name, zoneName), + content: record.content, + ttl: 3600, + ...(record.priority == null ? {} : { prio: record.priority }), + }), + }); + await parseDnsimpleJsonOrThrow( + response, + "Failed to create DNSimple DNS record for managed email onboarding", + ); +} + +type DesiredDnsRecord = { + type: "TXT" | "CNAME" | "MX", + name: string, + content: string, + priority?: number, +}; + +function getManagedDmarcDesiredRecord(subdomain: string): DesiredDnsRecord { + return { + type: "TXT", + name: `_dmarc.${normalizeDomainName(subdomain)}`, + content: "v=DMARC1; p=none", + }; +} + +function resendRecordToDesiredDnsRecord(record: ResendDomainRecord, subdomain: string): DesiredDnsRecord | null { + const recordType = record.type.toUpperCase(); + if (recordType !== "TXT" && recordType !== "CNAME" && recordType !== "MX") { + return null; + } + + const normalizedName = normalizeRecordName(record.name, subdomain); + const normalizedContent = normalizeRecordContent(record.value); + if (!normalizedContent) { + return null; + } + return { + type: recordType, + name: normalizedName, + content: normalizedContent, + ...(recordType === "MX" && record.priority != null ? { priority: record.priority } : {}), + }; +} + +function recordsEqual(existingRecord: DnsimpleDnsRecord, desiredRecord: DesiredDnsRecord, zoneName: string) { + const sameName = normalizeRecordName(existingRecord.name, zoneName) === normalizeDomainName(desiredRecord.name); + const sameType = existingRecord.type.toUpperCase() === desiredRecord.type; + const sameContent = normalizeRecordContent(existingRecord.content) === normalizeRecordContent(desiredRecord.content); + const existingPriority = existingRecord.priority ?? existingRecord.prio ?? null; + const samePriority = desiredRecord.type !== "MX" || existingPriority === (desiredRecord.priority ?? null); + return sameName && sameType && sameContent && samePriority; +} + +async function upsertDnsimpleResendRecords(zoneName: string, subdomain: string, resendRecords: ResendDomainRecord[]) { + const existingRecords = await listDnsimpleDnsRecords(zoneName); + const desiredRecords = resendRecords + .map((record) => resendRecordToDesiredDnsRecord(record, subdomain)) + .filter((record): record is DesiredDnsRecord => record != null); + desiredRecords.push(getManagedDmarcDesiredRecord(subdomain)); + + for (const desiredRecord of desiredRecords) { + const recordsWithSameName = existingRecords.filter( + (existingRecord) => normalizeRecordName(existingRecord.name, zoneName) === normalizeDomainName(desiredRecord.name), + ); + const exactMatch = recordsWithSameName.find((existingRecord) => recordsEqual(existingRecord, desiredRecord, zoneName)); + if (exactMatch != null) { + continue; + } + + const hasCnameConflict = recordsWithSameName.some((existingRecord) => { + const existingType = existingRecord.type.toUpperCase(); + if (desiredRecord.type === "CNAME") { + return existingType !== "CNAME"; + } + return existingType === "CNAME"; + }); + if (hasCnameConflict) { + throw new StackAssertionError("Cannot create DNSimple DNS record because of CNAME conflict", { + zoneName, + desiredRecord, + }); + } + + if (desiredRecord.type === "CNAME" && recordsWithSameName.some((existingRecord) => existingRecord.type.toUpperCase() === "CNAME")) { + throw new StackAssertionError("DNSimple CNAME record already exists with different content", { + zoneName, + desiredRecord, + existingRecords: recordsWithSameName, + }); + } + + await createDnsimpleDnsRecord(zoneName, desiredRecord); + existingRecords.push({ + id: `created-${desiredRecord.type}-${desiredRecord.name}-${desiredRecord.content}`, + type: desiredRecord.type, + name: desiredRecord.name, + content: desiredRecord.content, + priority: desiredRecord.priority, + }); + } +} + +function isResendDomainAlreadyExistsResponse(responseBody: string) { + const lower = responseBody.toLowerCase(); + return lower.includes("already exists") || lower.includes("domain exists"); +} + +async function createResendDomain(subdomain: string): Promise { + const resendApiKey = getEnvVariable("STACK_RESEND_API_KEY"); + const response = await fetch("https://api.resend.com/domains", { + method: "POST", + headers: { + "Authorization": `Bearer ${resendApiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: subdomain, + }), + }); + + if (!response.ok) { + const responseBody = await response.text(); + if ((response.status === 403 || response.status === 409) && isResendDomainAlreadyExistsResponse(responseBody)) { + throw new StatusError(409, "This subdomain already exists in Resend. If this is from another project, choose a different subdomain."); + } + throw new StackAssertionError("Failed to create Resend domain for managed email onboarding", { + status: response.status, + responseBody, + }); + } + + const body = await response.json() as { id: string, name: string, records?: ResendDomainRecord[], status?: ResendDomain["status"] }; + + const verifyResponse = await fetch(`https://api.resend.com/domains/${body.id}/verify`, { + method: "POST", + headers: { + "Authorization": `Bearer ${resendApiKey}`, + "Content-Type": "application/json", + }, + }); + if (!verifyResponse.ok) { + const verifyResponseBody = await verifyResponse.text(); + throw new StackAssertionError("Failed to trigger Resend domain verification for managed email onboarding", { + status: verifyResponse.status, + responseBody: verifyResponseBody, + domainId: body.id, + }); + } + + return { + id: body.id, + name: body.name, + status: body.status, + records: body.records, + }; +} + +async function createResendScopedKey(options: { subdomain: string, domainId: string, tenancyId: string }): Promise { + const resendApiKey = getEnvVariable("STACK_RESEND_API_KEY"); + const response = await fetch("https://api.resend.com/api-keys", { + method: "POST", + headers: { + "Authorization": `Bearer ${resendApiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: `stack-managed-${options.tenancyId}-${options.subdomain}`, + permission: "sending_access", + domain_id: options.domainId, + }), + }); + const body = await parseJsonOrThrow<{ token?: string }>( + response, + "Failed to create scoped Resend API key while applying managed email domain", + ); + if (!body.token) { + throw new StackAssertionError("Resend did not return an API key token for managed onboarding", { + domainId: options.domainId, + tenancyId: options.tenancyId, + subdomain: options.subdomain, + }); + } + return body.token; +} + +function managedDomainToSetupResult(domain: ManagedEmailDomain): ManagedEmailSetupResult { + return { + domainId: domain.resendDomainId, + subdomain: domain.subdomain, + senderLocalPart: domain.senderLocalPart, + nameServerRecords: domain.nameServerRecords, + status: domain.status, + }; +} + +function managedDomainToListItem(domain: ManagedEmailDomain): ManagedEmailListItem { + return { + domainId: domain.resendDomainId, + subdomain: domain.subdomain, + senderLocalPart: domain.senderLocalPart, + status: domain.status, + nameServerRecords: domain.nameServerRecords, + verifiedAt: domain.verifiedAt?.getTime() ?? null, + appliedAt: domain.appliedAt?.getTime() ?? null, + }; +} + +export async function setupManagedEmailProvider(options: { subdomain: string, senderLocalPart: string, tenancy: Tenancy }): Promise { + const normalizedSubdomain = normalizeDomainName(options.subdomain); + assertValidManagedSubdomain(normalizedSubdomain); + assertValidManagedSenderLocalPart(options.senderLocalPart); + + const existing = await getManagedEmailDomainByTenancyAndSubdomain({ + tenancyId: options.tenancy.id, + subdomain: normalizedSubdomain, + }); + if (existing) { + if (existing.senderLocalPart !== options.senderLocalPart) { + throw new StatusError(409, "This subdomain is already tracked with a different sender local part"); + } + return managedDomainToSetupResult(existing); + } + + if (shouldUseMockManagedEmailOnboarding()) { + const row = await createManagedEmailDomain({ + tenancyId: options.tenancy.id, + projectId: options.tenancy.project.id, + branchId: options.tenancy.branchId, + subdomain: normalizedSubdomain, + 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", + }); + return managedDomainToSetupResult(row); + } + + const resendDomain = await createResendDomain(normalizedSubdomain); + const dnsimpleZone = await createOrReuseDnsimpleZone(normalizedSubdomain); + await upsertDnsimpleResendRecords(dnsimpleZone.name, normalizedSubdomain, resendDomain.records ?? []); + + const zoneNameServers = await getDnsimpleZoneNameServers(dnsimpleZone.name); + if (zoneNameServers.length === 0) { + throw new StackAssertionError("DNSimple zone was created without nameservers for managed email onboarding", { + zoneId: dnsimpleZone.id, + subdomain: normalizedSubdomain, + }); + } + + const row = await createManagedEmailDomain({ + tenancyId: options.tenancy.id, + projectId: options.tenancy.project.id, + branchId: options.tenancy.branchId, + subdomain: normalizedSubdomain, + senderLocalPart: options.senderLocalPart, + resendDomainId: resendDomain.id, + nameServerRecords: zoneNameServers, + status: resendDomain.status === "verified" ? "verified" : "pending_verification", + }); + return managedDomainToSetupResult(row); +} + +export async function checkManagedEmailProviderStatus(options: { + tenancy: Tenancy, + domainId: string, + subdomain: string, + senderLocalPart: string, +}): Promise { + const normalizedSubdomain = normalizeDomainName(options.subdomain); + assertValidManagedSubdomain(normalizedSubdomain); + assertValidManagedSenderLocalPart(options.senderLocalPart); + + const row = await getManagedEmailDomainByTenancyAndSubdomain({ + tenancyId: options.tenancy.id, + subdomain: normalizedSubdomain, + }); + if (!row || row.resendDomainId !== options.domainId || row.senderLocalPart !== options.senderLocalPart) { + throw new StatusError(404, "Managed domain setup not found for this project/branch"); + } + + return { + status: row.status, + }; +} + +export async function listManagedEmailProviderDomains(options: { tenancy: Tenancy }): Promise { + const rows = await listManagedEmailDomainsForTenancy(options.tenancy.id); + return rows.map(managedDomainToListItem); +} + +export async function applyManagedEmailProvider(options: { + tenancy: Tenancy, + domainId: string, +}): Promise { + const domain = await getManagedEmailDomainByResendDomainId(options.domainId); + 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") { + return { status: "applied" }; + } + if (domain.status !== "verified") { + throw new StatusError(409, "Managed domain is not verified yet"); + } + + const resendApiKey = shouldUseMockManagedEmailOnboarding() + ? `managed_mock_key_${options.tenancy.id}` + : await createResendScopedKey({ + subdomain: domain.subdomain, + domainId: domain.resendDomainId, + tenancyId: options.tenancy.id, + }); + + await saveManagedEmailProviderConfig({ + tenancy: options.tenancy, + resendApiKey, + subdomain: domain.subdomain, + senderLocalPart: domain.senderLocalPart, + }); + + await markManagedEmailDomainApplied(domain.id); + return { status: "applied" }; +} + +export async function processResendDomainWebhookEvent(options: { + domainId: string, + providerStatusRaw: string, + errorMessage?: string, +}) { + const statusLower = options.providerStatusRaw.toLowerCase(); + const mappedStatus: ManagedEmailDomainStatus = + statusLower === "verified" + ? "verified" + : statusLower === "failed" || statusLower === "partially_failed" || statusLower === "temporary_failure" + ? "failed" + : "pending_verification"; + + await updateManagedEmailDomainWebhookStatus({ + resendDomainId: options.domainId, + providerStatusRaw: options.providerStatusRaw, + status: mappedStatus, + lastError: mappedStatus === "failed" ? (options.errorMessage ?? options.providerStatusRaw) : null, + }); +} + +async function saveManagedEmailProviderConfig(options: { + tenancy: Tenancy, + resendApiKey: string, + subdomain: string, + senderLocalPart: string, +}) { + await overrideEnvironmentConfigOverride({ + projectId: options.tenancy.project.id, + branchId: options.tenancy.branchId, + environmentConfigOverrideOverride: { + "emails.server.isShared": false, + "emails.server.provider": "managed", + "emails.server.password": options.resendApiKey, + "emails.server.senderName": options.tenancy.project.display_name, + "emails.server.senderEmail": getManagedSenderEmail(options.subdomain, options.senderLocalPart), + "emails.server.managedSubdomain": options.subdomain, + "emails.server.managedSenderLocalPart": options.senderLocalPart, + }, + }); +} + diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index acafa4e095..78b6db54a1 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -224,6 +224,8 @@ export async function createOrUpdateProjectWithLegacyConfig( senderName: dataOptions.email_config.sender_name, senderEmail: dataOptions.email_config.sender_email, provider: "smtp", + managedSubdomain: undefined, + managedSenderLocalPart: undefined, } satisfies CompleteConfig['emails']['server'] : undefined, 'emails.selectedThemeId': dataOptions.email_theme, // ======================= rbac ======================= diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx index 91130bdcc8..ed79916668 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx @@ -164,11 +164,15 @@ function EmulatorModeCard() { function EmailServerCard({ emailConfig }: { emailConfig: CompleteConfig['emails']['server'] }) { const serverType = emailConfig.isShared ? 'Shared' - : (emailConfig.provider === 'resend' ? 'Resend' : 'Custom SMTP'); + : emailConfig.provider === 'managed' + ? 'Managed By Stack Auth' + : (emailConfig.provider === 'resend' ? 'Resend' : 'Custom SMTP'); const senderEmail = emailConfig.isShared ? 'noreply@stackframe.co' - : emailConfig.senderEmail; + : emailConfig.provider === 'managed' && emailConfig.managedSubdomain && emailConfig.managedSenderLocalPart + ? `${emailConfig.managedSenderLocalPart}@${emailConfig.managedSubdomain}` + : emailConfig.senderEmail; return ( @@ -191,6 +195,14 @@ function EmailServerCard({ emailConfig }: { emailConfig: CompleteConfig['emails' } /> )} + + + Managed Setup + + } + /> @@ -224,12 +236,235 @@ function EmailServerCard({ emailConfig }: { emailConfig: CompleteConfig['emails' {senderEmail} + + {emailConfig.provider === "managed" && ( +
+ + Managed Domain + + {emailConfig.managedSubdomain} +
+ )} + + {emailConfig.provider === "managed" && ( +
+ + Sender Local Part + + {emailConfig.managedSenderLocalPart} +
+ )}
); } +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 stackAdminApp = useAdminApp(); + const [open, setOpen] = useState(false); + const [setupState, setSetupState] = useState<{ + domainId: string, + nameServerRecords: string[], + subdomain: string, + senderLocalPart: string, + status: "pending_dns" | "pending_verification" | "verified" | "applied" | "failed", + } | null>(null); + const [domains, setDomains] = useState>([]); + const [error, setError] = useState(null); + const [loadingDomains, setLoadingDomains] = useState(false); + + const refreshDomains = async () => { + setLoadingDomains(true); + try { + const result = await stackAdminApp.listManagedEmailDomains(); + setDomains(result); + } finally { + setLoadingDomains(false); + } + }; + + 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, + }); + await refreshDomains(); + setError(null); + return "prevent-close" as const; + }} + render={(form) => ( + <> + {!setupState && ( + <> + + + + )} + {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}
+ ))} +
+
+
+ )} + {setupState && ( +
+ + +
+ )} + {(() => { + 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: {domain.status} + + + + )) + )} +
; + })()} + {error && {error}} + + )} + /> + ); +} + function EmailLogCard() { const stackAdminApp = useAdminApp(); const [emailLogs, setEmailLogs] = useState([]); @@ -392,6 +627,16 @@ const getDefaultValues = (emailConfig: CompleteConfig['emails']['server'] | unde senderName: emailConfig.senderName, password: emailConfig.password, } as const; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if (emailConfig.provider === 'managed') { + return { + type: 'managed', + senderEmail: emailConfig.managedSubdomain && emailConfig.managedSenderLocalPart + ? `${emailConfig.managedSenderLocalPart}@${emailConfig.managedSubdomain}` + : emailConfig.senderEmail, + senderName: emailConfig.senderName ?? project.displayName, + password: emailConfig.password, + } as const; } else { return { type: 'standard', @@ -406,7 +651,7 @@ const getDefaultValues = (emailConfig: CompleteConfig['emails']['server'] | unde }; const emailServerSchema = yup.object({ - type: yup.string().oneOf(['shared', 'standard', 'resend']).defined(), + type: yup.string().oneOf(['shared', 'standard', 'resend', 'managed']).defined(), host: definedWhenTypeIsOneOf(yup.string(), ["standard"], "Host is required"), port: definedWhenTypeIsOneOf(yup.number().min(0, "Port must be a number between 0 and 65535").max(65535, "Port must be a number between 0 and 65535"), ["standard"], "Port is required"), username: definedWhenTypeIsOneOf(yup.string(), ["standard"], "Username is required"), @@ -468,7 +713,7 @@ function InputFieldWithInfo({ name={name} control={control} type={type} - // Don't pass required prop - it adds asterisk which we don't want + // Don't pass required prop - it adds asterisk which we don't want /> ); } @@ -508,6 +753,8 @@ function EditEmailServerDialog(props: { senderEmail: emailConfig.senderEmail, senderName: emailConfig.senderName, provider: emailConfig.type === 'resend' ? 'resend' : 'smtp', + managedSubdomain: undefined, + managedSenderLocalPart: undefined, } satisfies CompleteConfig['emails']['server'] }, pushable: false, @@ -540,6 +787,13 @@ function EditEmailServerDialog(props: { }, pushable: false, }); + } else if (values.type === 'managed') { + // Managed config is set through the ManagedEmailSetupDialog; just close + toast({ + title: "Email server unchanged", + description: "Managed email configuration is controlled through the managed domain setup.", + variant: 'success', + }); } else if (values.type === 'resend') { if (!values.password || !values.senderEmail || !values.senderName) { throwErr("Missing email server config for Resend"); @@ -584,6 +838,7 @@ function EditEmailServerDialog(props: { control={form.control} options={[ { label: "Shared (noreply@stackframe.co)", value: 'shared' }, + { label: "Managed (via managed domain setup)", value: 'managed' }, { label: "Resend (your own email address)", value: 'resend' }, { label: "Custom SMTP server (your own email address)", value: 'standard' }, ]} @@ -618,6 +873,21 @@ function EditEmailServerDialog(props: { } + {form.watch('type') === 'managed' && <> + + Managed Email Domain + + + This email server was configured through the managed domain setup flow. To change the domain or sender, use the managed email setup dialog above. + + {defaultValues.type === 'managed' && defaultValues.senderEmail && ( + + Sender: {defaultValues.senderName ? `${defaultValues.senderName} <${defaultValues.senderEmail}>` : defaultValues.senderEmail} + + )} + + + } {form.watch('type') === 'standard' && <> >( const formId = `${useId()}-form`; const [submitting, setSubmitting] = useState(false); const [openState, setOpenState] = useState(false); + const okButton = props.okButton === false ? false : { + onClick: async () => "prevent-close" as const, + ...(typeof props.okButton === "boolean" ? {} : props.okButton), + props: { + form: formId, + type: "submit" as const, + loading: submitting, + ...((typeof props.okButton === "boolean") ? {} : props.okButton?.props), + }, + }; const handleSubmit = async (values: yup.InferType) => { const res = await props.onSubmit(values); if (res !== 'prevent-close') { @@ -34,16 +44,7 @@ export function SmartFormDialog>( setOpenState(open); props.onOpenChange?.(open); }} - okButton={{ - onClick: async () => "prevent-close", - ...(typeof props.okButton === "boolean" ? {} : props.okButton), - props: { - form: formId, - type: "submit", - loading: submitting, - ...((typeof props.okButton === "boolean") ? {} : props.okButton?.props) - }, - }} + okButton={okButton} > @@ -67,6 +68,16 @@ export function FormDialog( }); const [openState, setOpenState] = useState(false); const [submitting, setSubmitting] = useState(false); + const okButton = props.okButton === false ? false : { + onClick: async () => "prevent-close" as const, + ...(typeof props.okButton == "boolean" ? {} : props.okButton), + props: { + form: formId, + type: "submit" as const, + loading: submitting, + ...((typeof props.okButton == "boolean") ? {} : props.okButton?.props), + }, + }; const onSubmit = async (values: F, e?: React.BaseSyntheticEvent) => { e?.preventDefault(); @@ -122,16 +133,7 @@ export function FormDialog( setOpenState(false); runAsynchronouslyWithAlert(props.onClose?.()); }} - okButton={{ - onClick: async () => "prevent-close", - ...(typeof props.okButton == "boolean" ? {} : props.okButton), - props: { - form: formId, - type: "submit", - loading: submitting, - ...((typeof props.okButton == "boolean") ? {} : props.okButton?.props) - }, - }} + okButton={okButton} >
{ + it("rejects client access for setup endpoint", async ({ expect }) => { + await Project.createAndSwitch(); + + const response = await niceBackendFetch("/api/v1/internal/emails/managed-onboarding/setup", { + method: "POST", + accessType: "client", + body: { + subdomain: "mail.example.com", + sender_local_part: "noreply", + }, + }); + + 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", +