From 808a9ee2b5aebca67ae00f2d5e32801ef70e1139 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 25 Feb 2026 09:18:35 -0800 Subject: [PATCH 1/8] managed email provider --- apps/backend/.env.development | 6 + .../migration.sql | 28 + apps/backend/prisma/schema.prisma | 38 + .../integrations/resend/webhooks/route.tsx | 100 +++ .../emails/managed-onboarding/apply/route.tsx | 40 ++ .../emails/managed-onboarding/check/route.tsx | 44 ++ .../emails/managed-onboarding/list/route.tsx | 48 ++ .../emails/managed-onboarding/setup/route.tsx | 50 ++ apps/backend/src/lib/config.tsx | 10 + apps/backend/src/lib/emails.tsx | 19 + .../backend/src/lib/managed-email-domains.tsx | 216 ++++++ .../src/lib/managed-email-onboarding.tsx | 680 ++++++++++++++++++ apps/backend/src/lib/projects.tsx | 2 + .../[projectId]/emails/page-client.tsx | 275 ++++++- apps/dashboard/src/components/form-dialog.tsx | 42 +- .../internal/managed-email-onboarding.test.ts | 122 ++++ .../src/config/schema-fuzzer.test.ts | 4 +- packages/stack-shared/src/config/schema.ts | 20 +- .../src/interface/admin-interface.ts | 63 ++ .../apps/implementations/admin-app-impl.ts | 44 +- .../stack-app/apps/interfaces/admin-app.ts | 24 + 21 files changed, 1839 insertions(+), 36 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260224000000_managed_email_domains/migration.sql create mode 100644 apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx create mode 100644 apps/backend/src/app/api/latest/internal/emails/managed-onboarding/apply/route.tsx create mode 100644 apps/backend/src/app/api/latest/internal/emails/managed-onboarding/check/route.tsx create mode 100644 apps/backend/src/app/api/latest/internal/emails/managed-onboarding/list/route.tsx create mode 100644 apps/backend/src/app/api/latest/internal/emails/managed-onboarding/setup/route.tsx create mode 100644 apps/backend/src/lib/managed-email-domains.tsx create mode 100644 apps/backend/src/lib/managed-email-onboarding.tsx create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/internal/managed-email-onboarding.test.ts diff --git a/apps/backend/.env.development b/apps/backend/.env.development index f296d46e63..ed4aa2f992 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -95,3 +95,9 @@ 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_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..4907faded3 --- /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 DEFAULT gen_random_uuid(), + "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 DEFAULT CURRENT_TIMESTAMP, + + 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 93d73842c9..205f92bd29 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 b7a446a834..1dadff6e25 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -1084,6 +1084,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 rows = await globalPrismaClient.$queryRaw(Prisma.sql` + INSERT INTO "ManagedEmailDomain" ( + "tenancyId", + "projectId", + "branchId", + "subdomain", + "senderLocalPart", + "resendDomainId", + "nameServerRecords", + "status", + "isActive" + ) VALUES ( + ${options.tenancyId}, + ${options.projectId}, + ${options.branchId}, + ${options.subdomain}, + ${options.senderLocalPart}, + ${options.resendDomainId}, + ${JSON.stringify(options.nameServerRecords)}::jsonb, + ${statusToDbStatus(options.status)}::"ManagedEmailDomainStatus", + true + ) + RETURNING * + `); + if (rows.length === 0) { + throw new StackAssertionError("Failed to insert ManagedEmailDomain row"); + } + return mapRow(rows[0]!); +} + +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..2f634e527d --- /dev/null +++ b/apps/backend/src/lib/managed-email-onboarding.tsx @@ -0,0 +1,680 @@ +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") { + const resendApiKey = getEnvVariable("STACK_RESEND_API_KEY", ""); + const dnsimpleApiToken = getEnvVariable("STACK_DNSIMPLE_API_TOKEN", ""); + const dnsimpleAccountId = getEnvVariable("STACK_DNSIMPLE_ACCOUNT_ID", ""); + if (resendApiKey.startsWith("mock_") || dnsimpleApiToken.length === 0 || dnsimpleAccountId.length === 0) { + return true; + } + } + + return false; +} + +function assertValidManagedSubdomain(subdomain: string) { + if (!/^[a-zA-Z0-9.-]+$/.test(subdomain) || !subdomain.includes(".")) { + 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..1fdbf07775 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 Resend' + : (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,229 @@ 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); + const result = await stackAdminApp.listManagedEmailDomains(); + setDomains(result); + 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={{ 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 && ( + + 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 && ( +
+ + +
+ )} +
+ Tracked managed domains + {loadingDomains ? ( + Loading managed domains... + ) : domains.length === 0 ? ( + No managed domains tracked yet. + ) : ( + domains.map((domain) => ( + + {domain.senderLocalPart}@{domain.subdomain} + + Status: {domain.status} + + + + )) + )} +
+ {error && {error}} + + )} + /> + ); +} + function EmailLogCard() { const stackAdminApp = useAdminApp(); const [emailLogs, setEmailLogs] = useState([]); @@ -392,6 +621,14 @@ 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: 'resend', + 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', @@ -468,7 +705,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 +745,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, @@ -707,9 +946,11 @@ function TestSendingDialog(props: { } const missingFields: string[] = []; - if (!emailServerConfig.host) missingFields.push("host"); - if (!emailServerConfig.port) missingFields.push("port"); - if (!emailServerConfig.username) missingFields.push("username"); + if (emailServerConfig.provider !== "managed") { + if (!emailServerConfig.host) missingFields.push("host"); + if (!emailServerConfig.port) missingFields.push("port"); + if (!emailServerConfig.username) missingFields.push("username"); + } if (!emailServerConfig.password) missingFields.push("password"); if (!emailServerConfig.senderName) missingFields.push("sender name"); if (!emailServerConfig.senderEmail) missingFields.push("sender email"); @@ -719,14 +960,24 @@ function TestSendingDialog(props: { } // Convert CompleteConfig email server to AdminEmailConfig format - const emailConfig: AdminEmailConfig = emailServerConfig.provider === 'resend' ? { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const emailConfig: AdminEmailConfig = emailServerConfig.provider === 'resend' || emailServerConfig.provider === 'managed' ? { type: 'resend', - host: emailServerConfig.host ?? throwErr("Email host is missing"), - port: emailServerConfig.port ?? throwErr("Email port is missing"), - username: emailServerConfig.username ?? throwErr("Email username is missing"), + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + host: emailServerConfig.provider === "managed" ? "smtp.resend.com" : (emailServerConfig.host ?? throwErr("Email host is missing")), + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + port: emailServerConfig.provider === "managed" ? 465 : (emailServerConfig.port ?? throwErr("Email port is missing")), + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + username: emailServerConfig.provider === "managed" ? "resend" : (emailServerConfig.username ?? throwErr("Email username is missing")), password: emailServerConfig.password ?? throwErr("Email password is missing"), - senderName: emailServerConfig.senderName ?? throwErr("Email sender name is missing"), - senderEmail: emailServerConfig.senderEmail ?? throwErr("Email sender email is missing"), + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + senderName: emailServerConfig.provider === "managed" ? project.displayName : (emailServerConfig.senderName ?? throwErr("Email sender name is missing")), + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + senderEmail: emailServerConfig.provider === "managed" + ? (emailServerConfig.managedSubdomain && emailServerConfig.managedSenderLocalPart + ? `${emailServerConfig.managedSenderLocalPart}@${emailServerConfig.managedSubdomain}` + : throwErr("Managed sender config is missing")) + : (emailServerConfig.senderEmail ?? throwErr("Email sender email is missing")), } : { type: 'standard', host: emailServerConfig.host ?? throwErr("Email host is missing"), diff --git a/apps/dashboard/src/components/form-dialog.tsx b/apps/dashboard/src/components/form-dialog.tsx index 2b7c9d33f7..5943175e43 100644 --- a/apps/dashboard/src/components/form-dialog.tsx +++ b/apps/dashboard/src/components/form-dialog.tsx @@ -17,6 +17,16 @@ export function SmartFormDialog>( 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", +