From 9854ce2f74f51531f75f4c258a0f0f1669994798 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 23 Feb 2026 20:04:08 -0800 Subject: [PATCH] emails managed provider --- apps/backend/.env.development | 4 + .../emails/managed-onboarding/check/route.tsx | 46 ++ .../emails/managed-onboarding/setup/route.tsx | 44 ++ apps/backend/src/lib/config.tsx | 10 + apps/backend/src/lib/emails.tsx | 19 + .../src/lib/managed-email-onboarding.test.tsx | 184 +++++++ .../src/lib/managed-email-onboarding.tsx | 496 ++++++++++++++++++ apps/backend/src/lib/projects.tsx | 2 + .../[projectId]/emails/page-client.tsx | 230 +++++++- apps/dashboard/src/components/form-dialog.tsx | 42 +- .../internal/managed-email-onboarding.test.ts | 91 ++++ claude/CLAUDE-KNOWLEDGE.md | 15 + ...l-onboarding-no-poller-managed-provider.md | 48 ++ ...end-email-onboarding-dnsimple-condensed.md | 45 ++ .../src/config/schema-fuzzer.test.ts | 4 +- packages/stack-shared/src/config/schema.ts | 20 +- .../src/interface/admin-interface.ts | 29 + .../apps/implementations/admin-app-impl.ts | 30 +- .../stack-app/apps/interfaces/admin-app.ts | 11 + 19 files changed, 1337 insertions(+), 33 deletions(-) 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/setup/route.tsx create mode 100644 apps/backend/src/lib/managed-email-onboarding.test.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 create mode 100644 docs/plans/managed-email-onboarding-no-poller-managed-provider.md create mode 100644 docs/plans/resend-email-onboarding-dnsimple-condensed.md diff --git a/apps/backend/.env.development b/apps/backend/.env.development index f296d46e6..8ee0bf963 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -95,3 +95,7 @@ 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 + 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 000000000..0cca2c96e --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/emails/managed-onboarding/check/route.tsx @@ -0,0 +1,46 @@ +import { checkManagedEmailProviderStatus } 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({ + 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", "complete"]).defined(), + missing_name_server_records: yupArray(yupString().defined()).optional(), + }).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, + ...(checkResult.status === "pending" ? { missing_name_server_records: checkResult.missingNameServerRecords } : {}), + }, + }; + }, +}); 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 000000000..cd7f298f0 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/emails/managed-onboarding/setup/route.tsx @@ -0,0 +1,44 @@ +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(), + name_server_records: yupArray(yupString().defined()).defined(), + }).defined(), + }), + handler: async ({ auth, body }) => { + const setupResult = await setupManagedEmailProvider({ + subdomain: body.subdomain, + senderLocalPart: body.sender_local_part, + tenancyId: auth.tenancy.id, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + domain_id: setupResult.domainId, + name_server_records: setupResult.nameServerRecords, + }, + }; + }, +}); diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index b7a446a83..1dadff6e2 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 382093f92..bf54982aa 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 { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it("creates resend records in an existing Cloudflare zone and returns Cloudflare nameservers", async () => { + setupCommonEnv(); + + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const responses: Response[] = [ + jsonResponse({ + id: "resend_domain_123", + name: "mail.customer-example.com", + records: [ + { record: "TXT", type: "TXT", name: "mail.customer-example.com", value: "v=spf1 include:amazonses.com ~all", status: "pending" }, + { record: "CNAME", type: "CNAME", name: "em.mail.customer-example.com", value: "u123.wl.sendgrid.net", status: "pending" }, + { record: "MX", type: "MX", name: "mail.customer-example.com", value: "feedback-smtp.us-east-1.amazonses.com", priority: 10, status: "pending" }, + { record: "NS", type: "NS", name: "mail.customer-example.com", value: "ignored.ns.example.com", status: "pending" }, + ], + }), + jsonResponse({ + success: true, + errors: [], + result: [ + { + id: "zone_123", + name: "mail.customer-example.com", + name_servers: ["alex.ns.cloudflare.com", "jamie.ns.cloudflare.com"], + }, + ], + }), + jsonResponse({ + success: true, + errors: [], + result: [], + }), + jsonResponse({ + success: true, + errors: [], + result: { + id: "record_txt_1", + type: "TXT", + name: "mail.customer-example.com", + content: "v=spf1 include:amazonses.com ~all", + }, + }), + jsonResponse({ + success: true, + errors: [], + result: { + id: "record_cname_1", + type: "CNAME", + name: "em.mail.customer-example.com", + content: "u123.wl.sendgrid.net", + }, + }), + jsonResponse({ + success: true, + errors: [], + result: { + id: "record_mx_1", + type: "MX", + name: "mail.customer-example.com", + content: "feedback-smtp.us-east-1.amazonses.com", + priority: 10, + }, + }), + ]; + + fetchSpy.mockImplementation(async () => { + const response = responses.shift(); + if (response == null) { + throw new Error("Unexpected fetch call in managed-email-onboarding test"); + } + return response; + }); + + const result = await setupManagedEmailProvider({ + subdomain: "mail.customer-example.com", + senderLocalPart: "noreply", + tenancyId: "tenancy_123", + }); + + expect(result).toEqual({ + domainId: "resend_domain_123", + nameServerRecords: ["alex.ns.cloudflare.com", "jamie.ns.cloudflare.com"], + }); + + const createRecordCalls = fetchSpy.mock.calls + .filter((call) => typeof call[0] === "string" && call[0].toString().includes("/dns_records") && call[1]?.method === "POST"); + expect(createRecordCalls).toHaveLength(3); + }); + + it("fails loudly when a CNAME would conflict with existing DNS records", async () => { + setupCommonEnv(); + + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const responses: Response[] = [ + jsonResponse({ + id: "resend_domain_456", + name: "mail.customer-two.com", + records: [ + { record: "CNAME", type: "CNAME", name: "mail.customer-two.com", value: "u456.wl.sendgrid.net", status: "pending" }, + ], + }), + jsonResponse({ + success: true, + errors: [], + result: [ + { + id: "zone_456", + name: "mail.customer-two.com", + name_servers: ["alex.ns.cloudflare.com", "jamie.ns.cloudflare.com"], + }, + ], + }), + jsonResponse({ + success: true, + errors: [], + result: [ + { + id: "existing_txt", + type: "TXT", + name: "mail.customer-two.com", + content: "v=spf1 include:amazonses.com ~all", + }, + ], + }), + ]; + + fetchSpy.mockImplementation(async () => { + const response = responses.shift(); + if (response == null) { + throw new Error("Unexpected fetch call in managed-email-onboarding conflict test"); + } + return response; + }); + + await expect(setupManagedEmailProvider({ + subdomain: "mail.customer-two.com", + senderLocalPart: "noreply", + tenancyId: "tenancy_456", + })).rejects.toThrowError("Cannot create Cloudflare DNS record because of CNAME conflict"); + }); + + it("uses mock onboarding automatically in development when resend key is a mock key", async () => { + vi.stubEnv("NODE_ENV", "development"); + vi.stubEnv("STACK_RESEND_API_KEY", "mock_resend_api_key"); + + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const result = await setupManagedEmailProvider({ + subdomain: "mail.customer-three.com", + senderLocalPart: "noreply", + tenancyId: "tenancy_789", + }); + + expect(result).toEqual({ + domainId: "managed_mock_tenancy_789_mail_customer-three_com", + nameServerRecords: ["alex.ns.cloudflare.com", "jamie.ns.cloudflare.com"], + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); 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 000000000..510f47a64 --- /dev/null +++ b/apps/backend/src/lib/managed-email-onboarding.tsx @@ -0,0 +1,496 @@ +import { overrideEnvironmentConfigOverride } from "@/lib/config"; +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?: string, + records?: ResendDomainRecord[], +}; + +type ManagedEmailSetupResult = { + domainId: string, + nameServerRecords: string[], +}; + +type ManagedEmailCheckResult = + | { status: "pending", missingNameServerRecords: string[] } + | { status: "complete" }; + +function shouldUseMockManagedEmailOnboarding() { + const nodeEnvironment = getNodeEnvironment(); + if (nodeEnvironment === "test") { + return true; + } + + if (nodeEnvironment === "development") { + const resendApiKey = getEnvVariable("STACK_RESEND_API_KEY", ""); + if (resendApiKey.startsWith("mock_")) { + 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; + } + return normalizedName.endsWith(`.${normalizedZoneName}`) + ? normalizedName + : `${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 CloudflareApiError = { + code?: number, + message?: string, +}; + +type CloudflareApiResponse = { + success?: boolean, + errors?: CloudflareApiError[], + result?: T, +}; + +type CloudflareZone = { + id: string, + name: string, + name_servers?: string[], +}; + +type CloudflareDnsRecord = { + id: string, + type: string, + name: string, + content: string, + priority?: number, +}; + +async function parseCloudflareJsonOrThrow(response: Response, errorContext: string): Promise { + const body = await parseJsonOrThrow>(response, errorContext); + if (!body.success || !body.result) { + throw new StackAssertionError(errorContext, { + cloudflareErrors: body.errors, + cloudflareSuccess: body.success, + }); + } + return body.result; +} + +function getCloudflareBaseUrl() { + return getEnvVariable("STACK_CLOUDFLARE_API_BASE_URL", "https://api.cloudflare.com/client/v4"); +} + +function getCloudflareHeaders() { + return { + "Authorization": `Bearer ${getEnvVariable("STACK_CLOUDFLARE_API_TOKEN")}`, + "Content-Type": "application/json", + "Accept": "application/json", + }; +} + +async function listCloudflareZones(subdomain: string): Promise { + const cloudflareBaseUrl = getCloudflareBaseUrl(); + const cloudflareAccountId = getEnvVariable("STACK_CLOUDFLARE_ACCOUNT_ID"); + const response = await fetch(`${cloudflareBaseUrl}/zones?name=${encodeURIComponent(subdomain)}&account.id=${encodeURIComponent(cloudflareAccountId)}&page=1&per_page=50`, { + method: "GET", + headers: getCloudflareHeaders(), + }); + const zones = await parseCloudflareJsonOrThrow( + response, + "Failed to list Cloudflare zones for managed email onboarding", + ); + return zones.filter((zone) => normalizeDomainName(zone.name) === normalizeDomainName(subdomain)); +} + +async function getCloudflareZoneById(zoneId: string): Promise { + const cloudflareBaseUrl = getCloudflareBaseUrl(); + const response = await fetch(`${cloudflareBaseUrl}/zones/${encodeURIComponent(zoneId)}`, { + method: "GET", + headers: getCloudflareHeaders(), + }); + return await parseCloudflareJsonOrThrow( + response, + "Failed to fetch Cloudflare zone details for managed email onboarding", + ); +} + +async function createCloudflareZone(subdomain: string): Promise { + const cloudflareBaseUrl = getCloudflareBaseUrl(); + const cloudflareAccountId = getEnvVariable("STACK_CLOUDFLARE_ACCOUNT_ID"); + const response = await fetch(`${cloudflareBaseUrl}/zones`, { + method: "POST", + headers: getCloudflareHeaders(), + body: JSON.stringify({ + account: { + id: cloudflareAccountId, + }, + name: normalizeDomainName(subdomain), + type: "full", + jump_start: false, + }), + }); + return await parseCloudflareJsonOrThrow( + response, + "Failed to create Cloudflare zone for managed email onboarding", + ); +} + +async function createOrReuseCloudflareZone(subdomain: string): Promise { + const existingZones = await listCloudflareZones(subdomain); + if (existingZones.length > 1) { + throw new StackAssertionError("Multiple Cloudflare zones found for managed email onboarding subdomain", { + subdomain, + zoneIds: existingZones.map((zone) => zone.id), + }); + } + const zone = existingZones[0] ?? await createCloudflareZone(subdomain); + if (zone.name_servers?.length) { + return zone; + } + return await getCloudflareZoneById(zone.id); +} + +async function listCloudflareDnsRecords(zoneId: string): Promise { + const cloudflareBaseUrl = getCloudflareBaseUrl(); + const response = await fetch(`${cloudflareBaseUrl}/zones/${encodeURIComponent(zoneId)}/dns_records?page=1&per_page=5000`, { + method: "GET", + headers: getCloudflareHeaders(), + }); + return await parseCloudflareJsonOrThrow( + response, + "Failed to list Cloudflare DNS records for managed email onboarding", + ); +} + +async function createCloudflareDnsRecord(zoneId: string, record: { + type: string, + name: string, + content: string, + priority?: number, +}) { + const cloudflareBaseUrl = getCloudflareBaseUrl(); + const response = await fetch(`${cloudflareBaseUrl}/zones/${encodeURIComponent(zoneId)}/dns_records`, { + method: "POST", + headers: getCloudflareHeaders(), + body: JSON.stringify({ + type: record.type, + name: record.name, + content: record.content, + ttl: 1, + ...(record.priority == null ? {} : { priority: record.priority }), + }), + }); + await parseCloudflareJsonOrThrow( + response, + "Failed to create Cloudflare DNS record for managed email onboarding", + ); +} + +type DesiredCloudflareDnsRecord = { + type: "TXT" | "CNAME" | "MX", + name: string, + content: string, + priority?: number, +}; + +function resendRecordToDesiredCloudflareRecord(record: ResendDomainRecord, subdomain: string): DesiredCloudflareDnsRecord | 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: CloudflareDnsRecord, desiredRecord: DesiredCloudflareDnsRecord) { + const sameName = normalizeDomainName(existingRecord.name) === normalizeDomainName(desiredRecord.name); + const sameType = existingRecord.type.toUpperCase() === desiredRecord.type; + const sameContent = normalizeRecordContent(existingRecord.content) === normalizeRecordContent(desiredRecord.content); + const samePriority = desiredRecord.type !== "MX" || (existingRecord.priority ?? null) === (desiredRecord.priority ?? null); + return sameName && sameType && sameContent && samePriority; +} + +async function upsertCloudflareResendRecords(zoneId: string, subdomain: string, resendRecords: ResendDomainRecord[]) { + const existingRecords = await listCloudflareDnsRecords(zoneId); + const desiredRecords = resendRecords + .map((record) => resendRecordToDesiredCloudflareRecord(record, subdomain)) + .filter((record): record is DesiredCloudflareDnsRecord => record != null); + + for (const desiredRecord of desiredRecords) { + const recordsWithSameName = existingRecords.filter( + (existingRecord) => normalizeDomainName(existingRecord.name) === normalizeDomainName(desiredRecord.name), + ); + const exactMatch = recordsWithSameName.find((existingRecord) => recordsEqual(existingRecord, desiredRecord)); + 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 Cloudflare DNS record because of CNAME conflict", { + zoneId, + desiredRecord, + }); + } + + if (desiredRecord.type === "CNAME" && recordsWithSameName.some((existingRecord) => existingRecord.type.toUpperCase() === "CNAME")) { + throw new StackAssertionError("Cloudflare CNAME record already exists with different content", { + zoneId, + desiredRecord, + existingRecords: recordsWithSameName, + }); + } + + await createCloudflareDnsRecord(zoneId, desiredRecord); + existingRecords.push({ + id: `created-${desiredRecord.type}-${desiredRecord.name}-${desiredRecord.content}`, + type: desiredRecord.type, + name: desiredRecord.name, + content: desiredRecord.content, + priority: desiredRecord.priority, + }); + } +} + +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, + }), + }); + + const body = await parseJsonOrThrow<{ id: string, name: string, records?: ResendDomainRecord[] }>( + response, + "Failed to create Resend domain for managed email onboarding", + ); + + return { + id: body.id, + name: body.name, + records: body.records, + }; +} + +async function getResendDomain(domainId: string): Promise { + const resendApiKey = getEnvVariable("STACK_RESEND_API_KEY"); + const response = await fetch(`https://api.resend.com/domains/${encodeURIComponent(domainId)}`, { + method: "GET", + headers: { + "Authorization": `Bearer ${resendApiKey}`, + "Content-Type": "application/json", + }, + }); + const body = await parseJsonOrThrow<{ id: string, name: string, status?: string, records?: ResendDomainRecord[] }>( + response, + "Failed to fetch Resend domain during managed email onboarding check", + ); + return body; +} + +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 during managed email onboarding check", + ); + 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; +} + +export async function setupManagedEmailProvider(options: { subdomain: string, senderLocalPart: string, tenancyId: string }): Promise { + assertValidManagedSubdomain(options.subdomain); + assertValidManagedSenderLocalPart(options.senderLocalPart); + + if (shouldUseMockManagedEmailOnboarding()) { + return { + domainId: `managed_mock_${options.tenancyId}_${options.subdomain}`.replace(/[^a-zA-Z0-9_-]/g, "_"), + nameServerRecords: ["alex.ns.cloudflare.com", "jamie.ns.cloudflare.com"], + }; + } + + const resendDomain = await createResendDomain(options.subdomain); + const cloudflareZone = await createOrReuseCloudflareZone(options.subdomain); + await upsertCloudflareResendRecords(cloudflareZone.id, options.subdomain, resendDomain.records ?? []); + + const zoneNameServers = cloudflareZone.name_servers ?? []; + if (zoneNameServers.length === 0) { + throw new StackAssertionError("Cloudflare zone was created without nameservers for managed email onboarding", { + zoneId: cloudflareZone.id, + subdomain: options.subdomain, + }); + } + + return { + domainId: resendDomain.id, + nameServerRecords: zoneNameServers, + }; +} + +export async function checkManagedEmailProviderStatus(options: { + tenancy: Tenancy, + domainId: string, + subdomain: string, + senderLocalPart: string, +}): Promise { + assertValidManagedSubdomain(options.subdomain); + assertValidManagedSenderLocalPart(options.senderLocalPart); + + if (shouldUseMockManagedEmailOnboarding()) { + const mockApiKey = `managed_mock_key_${options.tenancy.id}`; + await saveManagedEmailProviderConfig({ + tenancy: options.tenancy, + resendApiKey: mockApiKey, + subdomain: options.subdomain, + senderLocalPart: options.senderLocalPart, + }); + return { status: "complete" }; + } + + const resendDomain = await getResendDomain(options.domainId); + const notVerifiedRecords = resendDomain.records + ?.filter((record) => record.record === "NS" || record.type === "NS") + .filter((record) => record.status !== "verified") + .map((record) => record.value) + .filter((value) => value.length > 0) ?? []; + + if ((resendDomain.status !== "verified" && notVerifiedRecords.length > 0) || notVerifiedRecords.length > 0) { + return { + status: "pending", + missingNameServerRecords: notVerifiedRecords, + }; + } + + const resendApiKey = await createResendScopedKey({ + subdomain: options.subdomain, + domainId: options.domainId, + tenancyId: options.tenancy.id, + }); + + await saveManagedEmailProviderConfig({ + tenancy: options.tenancy, + resendApiKey, + subdomain: options.subdomain, + senderLocalPart: options.senderLocalPart, + }); + + return { status: "complete" }; +} + +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, + provider: "managed", + host: undefined, + port: undefined, + username: undefined, + password: options.resendApiKey, + senderName: options.tenancy.project.display_name, + senderEmail: getManagedSenderEmail(options.subdomain, options.senderLocalPart), + managedSubdomain: options.subdomain, + managedSenderLocalPart: options.senderLocalPart, + } as Tenancy["config"]["emails"]["server"], + }, + }); +} diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index acafa4e09..78b6db54a 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 91130bdcc..985b14ed8 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 @@ -13,7 +13,7 @@ import { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; import { strictEmailSchema } from "@stackframe/stack-shared/dist/schema-fields"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { deepPlainEquals } from "@stackframe/stack-shared/dist/utils/objects"; -import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { runAsynchronouslyWithAlert, wait } from "@stackframe/stack-shared/dist/utils/promises"; import { ColumnDef, Table as TableType } from "@tanstack/react-table"; import { useEffect, useMemo, useState } from "react"; import * as yup from "yup"; @@ -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,192 @@ 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 { toast } = useToast(); + const [open, setOpen] = useState(false); + const [setupState, setSetupState] = useState<{ domainId: string, nameServerRecords: string[], subdomain: string, senderLocalPart: string } | null>(null); + const [checkStatus, setCheckStatus] = useState<"idle" | "pending" | "complete">("idle"); + const [missingNameServers, setMissingNameServers] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open || !setupState || checkStatus !== "pending") return; + + let cancelled = false; + runAsynchronouslyWithAlert(async () => { + while (!cancelled) { + const result = await stackAdminApp.checkManagedEmailStatus({ + domainId: setupState.domainId, + subdomain: setupState.subdomain, + senderLocalPart: setupState.senderLocalPart, + }); + + if (result.status === "complete") { + setCheckStatus("complete"); + setMissingNameServers([]); + toast({ + title: "Managed email enabled", + description: "Managed Resend provider is now configured for this project.", + variant: "success", + }); + return; + } + + setMissingNameServers(result.missingNameServerRecords); + await wait(3000); + } + }, { + onError: (err) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : "Managed onboarding status check failed"); + }, + }); + + return () => { + cancelled = true; + }; + }, [checkStatus, open, setupState, stackAdminApp, toast]); + + return ( + { + setOpen(newOpen); + if (!newOpen) { + setSetupState(null); + setCheckStatus("idle"); + setMissingNameServers([]); + 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: values.subdomain, + senderLocalPart: values.senderLocalPart, + }); + setError(null); + setMissingNameServers([]); + setCheckStatus("pending"); + return "prevent-close" as const; + }} + render={(form) => ( + <> + {setupState == null && ( + <> + + + + )} + {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}
+ ))} +
+
+
+ )} + {checkStatus === "pending" && ( + + Waiting for DNS propagation + + {missingNameServers.length > 0 ? `Missing NS records: ${missingNameServers.join(", ")}` : "Polling verification status..."} + + + )} + {checkStatus === "complete" && ( + + Managed email provider is ready + + )} + {error && {error}} + + )} + /> + ); +} + function EmailLogCard() { const stackAdminApp = useAdminApp(); const [emailLogs, setEmailLogs] = useState([]); @@ -392,6 +584,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', @@ -508,6 +708,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, @@ -719,14 +921,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 2b7c9d33f..5943175e4 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", +