Managed email provider (#1222)
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Lint & build / lint_and_build (latest) (push) Has been cancelled
Dev Environment Test With Custom Base Port / restart-dev-and-test-with-custom-base-port (push) Has been cancelled
Dev Environment Test / restart-dev-and-test (push) Has been cancelled
Run setup tests with custom base port / setup-tests-with-custom-base-port (push) Has been cancelled
Run setup tests / setup-tests (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled

<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Managed email domain onboarding: setup, DNS provisioning,
verification, status checks, and apply flow (Resend-backed).
* **UI**
* Project email settings: managed-provider setup dialog, managed sender
fields, status display, and test-send mapping.
* **Integrations**
* DNS provider automation and Resend webhook handling for domain status
updates; scoped keys for sending.
* **API**
* Admin endpoints / client APIs to setup, check, list, and apply managed
email domains.
* **Tests**
  * End-to-end tests covering the full onboarding flow.
* **Chores**
* Added environment variables and config schema support for Resend and
DNS integrations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
BilalG1
2026-03-09 20:23:11 -07:00
committed by GitHub
parent 57149bd84b
commit b701fdfb0a
21 changed files with 1867 additions and 37 deletions
+7
View File
@@ -95,3 +95,10 @@ STACK_CLICKHOUSE_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}36
STACK_CLICKHOUSE_ADMIN_USER=stackframe
STACK_CLICKHOUSE_ADMIN_PASSWORD=PASSWORD-PLACEHOLDER--9gKyMxJeMx
STACK_CLICKHOUSE_EXTERNAL_PASSWORD=PASSWORD-PLACEHOLDER--EZeHscBMzE
# Managed emails
STACK_RESEND_API_KEY=mock_resend_api_key
STACK_RESEND_WEBHOOK_SECRET=mock_resend_webhook_secret
STACK_DNSIMPLE_API_TOKEN=mock_dnsimple_api_token
STACK_DNSIMPLE_ACCOUNT_ID=mock_dnsimple_account_id
STACK_DNSIMPLE_API_BASE_URL=https://api.dnsimple.com/v2
@@ -0,0 +1,28 @@
CREATE TYPE "ManagedEmailDomainStatus" AS ENUM ('PENDING_DNS', 'PENDING_VERIFICATION', 'VERIFIED', 'APPLIED', 'FAILED');
CREATE TABLE "ManagedEmailDomain" (
"id" UUID NOT NULL,
"tenancyId" UUID NOT NULL,
"projectId" TEXT NOT NULL,
"branchId" TEXT NOT NULL,
"subdomain" TEXT NOT NULL,
"senderLocalPart" TEXT NOT NULL,
"resendDomainId" TEXT NOT NULL,
"nameServerRecords" JSONB NOT NULL,
"status" "ManagedEmailDomainStatus" NOT NULL DEFAULT 'PENDING_VERIFICATION',
"providerStatusRaw" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"lastError" TEXT,
"verifiedAt" TIMESTAMP(3),
"appliedAt" TIMESTAMP(3),
"lastWebhookAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ManagedEmailDomain_pkey" PRIMARY KEY ("id"),
CONSTRAINT "ManagedEmailDomain_tenancyId_fkey" FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "ManagedEmailDomain_resendDomainId_key" ON "ManagedEmailDomain"("resendDomainId");
CREATE UNIQUE INDEX "ManagedEmailDomain_tenancyId_subdomain_key" ON "ManagedEmailDomain"("tenancyId", "subdomain");
CREATE INDEX "ManagedEmailDomain_tenancy_status_active_idx" ON "ManagedEmailDomain"("tenancyId", "status", "isActive");
+38
View File
@@ -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
@@ -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<string, string[] | undefined>, 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,
},
};
},
});
@@ -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,
},
};
},
});
@@ -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,
},
};
},
});
@@ -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,
})),
},
};
},
});
@@ -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,
},
};
},
});
+10
View File
@@ -1090,6 +1090,16 @@ export const renderedOrganizationConfigToProjectCrud = (renderedConfig: Complete
email_config: renderedConfig.emails.server.isShared ? {
type: 'shared',
} : renderedConfig.emails.server.provider === "managed" ? {
type: 'standard',
host: "smtp.resend.com",
port: 465,
username: "resend",
password: renderedConfig.emails.server.password,
sender_name: renderedConfig.emails.server.senderName,
sender_email: renderedConfig.emails.server.managedSubdomain && renderedConfig.emails.server.managedSenderLocalPart
? `${renderedConfig.emails.server.managedSenderLocalPart}@${renderedConfig.emails.server.managedSubdomain}`
: renderedConfig.emails.server.senderEmail,
} : {
type: 'standard',
host: renderedConfig.emails.server.host,
+19
View File
@@ -104,6 +104,25 @@ export async function getEmailConfig(tenancy: Tenancy): Promise<LowLevelEmailCon
if (projectEmailConfig.isShared) {
return await getSharedEmailConfig(tenancy.project.display_name);
} else {
if (projectEmailConfig.provider === "managed") {
if (!projectEmailConfig.password || !projectEmailConfig.managedSubdomain || !projectEmailConfig.managedSenderLocalPart) {
throw new StackAssertionError("Managed email config is incomplete despite provider being managed", {
projectId: tenancy.id,
emailConfig: projectEmailConfig,
});
}
return {
host: "smtp.resend.com",
port: 465,
username: "resend",
password: projectEmailConfig.password,
senderEmail: `${projectEmailConfig.managedSenderLocalPart}@${projectEmailConfig.managedSubdomain}`,
senderName: tenancy.project.display_name,
secure: true,
type: "standard",
};
}
if (!projectEmailConfig.host || !projectEmailConfig.port || !projectEmailConfig.username || !projectEmailConfig.password || !projectEmailConfig.senderEmail || !projectEmailConfig.senderName) {
throw new StackAssertionError("Email config is not complete despite not being shared. This should never happen?", { projectId: tenancy.id, emailConfig: projectEmailConfig });
}
@@ -0,0 +1,210 @@
import { Prisma } from "@/generated/prisma/client";
import { globalPrismaClient } from "@/prisma-client";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
export type ManagedEmailDomainStatus = "pending_dns" | "pending_verification" | "verified" | "applied" | "failed";
type ManagedEmailDomainRow = {
id: string,
tenancyId: string,
projectId: string,
branchId: string,
subdomain: string,
senderLocalPart: string,
resendDomainId: string,
nameServerRecords: unknown,
status: "PENDING_DNS" | "PENDING_VERIFICATION" | "VERIFIED" | "APPLIED" | "FAILED",
providerStatusRaw: string | null,
isActive: boolean,
/**
* Internal diagnostic error from the email provider (e.g. Resend webhook failure reason).
* Not intended to be shown to end users directly.
*/
lastError: string | null,
verifiedAt: Date | null,
appliedAt: Date | null,
lastWebhookAt: Date | null,
createdAt: Date,
updatedAt: Date,
};
export type ManagedEmailDomain = {
id: string,
tenancyId: string,
projectId: string,
branchId: string,
subdomain: string,
senderLocalPart: string,
resendDomainId: string,
nameServerRecords: string[],
status: ManagedEmailDomainStatus,
providerStatusRaw: string | null,
isActive: boolean,
/**
* Internal diagnostic error from the email provider (e.g. Resend webhook failure reason).
* Not intended to be shown to end users directly.
*/
lastError: string | null,
verifiedAt: Date | null,
appliedAt: Date | null,
lastWebhookAt: Date | null,
createdAt: Date,
updatedAt: Date,
};
function dbStatusToStatus(status: ManagedEmailDomainRow["status"]): ManagedEmailDomainStatus {
if (status === "PENDING_DNS") return "pending_dns";
if (status === "PENDING_VERIFICATION") return "pending_verification";
if (status === "VERIFIED") return "verified";
if (status === "APPLIED") return "applied";
return "failed";
}
function statusToDbStatus(status: ManagedEmailDomainStatus): ManagedEmailDomainRow["status"] {
if (status === "pending_dns") return "PENDING_DNS";
if (status === "pending_verification") return "PENDING_VERIFICATION";
if (status === "verified") return "VERIFIED";
if (status === "applied") return "APPLIED";
return "FAILED";
}
function parseNameServerRecords(value: unknown): string[] {
if (!Array.isArray(value) || value.some((item) => 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<ManagedEmailDomain | null> {
const rows = await globalPrismaClient.$queryRaw<ManagedEmailDomainRow[]>(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<ManagedEmailDomain | null> {
const rows = await globalPrismaClient.$queryRaw<ManagedEmailDomainRow[]>(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<ManagedEmailDomain> {
const row = await globalPrismaClient.managedEmailDomain.create({
data: {
tenancyId: options.tenancyId,
projectId: options.projectId,
branchId: options.branchId,
subdomain: options.subdomain,
senderLocalPart: options.senderLocalPart,
resendDomainId: options.resendDomainId,
nameServerRecords: options.nameServerRecords,
status: statusToDbStatus(options.status),
isActive: true
}
});
return mapRow(row);
}
export async function updateManagedEmailDomainWebhookStatus(options: {
resendDomainId: string,
providerStatusRaw: string,
status: ManagedEmailDomainStatus,
lastError: string | null,
}): Promise<ManagedEmailDomain | null> {
const verifiedAt = options.status === "verified" ? Prisma.sql`CURRENT_TIMESTAMP` : Prisma.sql`"verifiedAt"`;
const rows = await globalPrismaClient.$queryRaw<ManagedEmailDomainRow[]>(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<ManagedEmailDomain> {
const rows = await globalPrismaClient.$queryRaw<ManagedEmailDomainRow[]>(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<ManagedEmailDomain[]> {
const rows = await globalPrismaClient.$queryRaw<ManagedEmailDomainRow[]>(Prisma.sql`
SELECT *
FROM "ManagedEmailDomain"
WHERE "tenancyId" = ${tenancyId}
ORDER BY "isActive" DESC, "updatedAt" DESC
`);
return rows.map(mapRow);
}
@@ -0,0 +1,679 @@
import { overrideEnvironmentConfigOverride } from "@/lib/config";
import {
ManagedEmailDomain,
ManagedEmailDomainStatus,
createManagedEmailDomain,
getManagedEmailDomainByResendDomainId,
getManagedEmailDomainByTenancyAndSubdomain,
listManagedEmailDomainsForTenancy,
markManagedEmailDomainApplied,
updateManagedEmailDomainWebhookStatus,
} from "@/lib/managed-email-domains";
import { Tenancy } from "@/lib/tenancies";
import { getNodeEnvironment, getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
type ResendDomainRecord = {
record: string,
name: string,
type: string,
value: string,
status: string,
priority?: number,
};
type ResendDomain = {
id: string,
name: string,
status?: "not_started" | "pending" | "verified" | "partially_verified" | "partially_failed" | "failed" | "temporary_failure",
records?: ResendDomainRecord[],
};
export type ManagedEmailSetupResult = {
domainId: string,
subdomain: string,
senderLocalPart: string,
nameServerRecords: string[],
status: ManagedEmailDomainStatus,
};
export type ManagedEmailCheckResult = {
status: ManagedEmailDomainStatus,
};
export type ManagedEmailApplyResult = {
status: "applied",
};
export type ManagedEmailListItem = {
domainId: string,
subdomain: string,
senderLocalPart: string,
status: ManagedEmailDomainStatus,
nameServerRecords: string[],
verifiedAt: number | null,
appliedAt: number | null,
};
function shouldUseMockManagedEmailOnboarding() {
const nodeEnvironment = getNodeEnvironment();
if (nodeEnvironment === "development" || nodeEnvironment === "test") {
const resendApiKey = getEnvVariable("STACK_RESEND_API_KEY", "");
if (resendApiKey === "mock_resend_api_key") {
return true;
}
}
return false;
}
function assertValidManagedSubdomain(subdomain: string) {
if (!/^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9-]{2,63}$/.test(subdomain)) {
throw new StatusError(400, "subdomain must be a fully-qualified domain name like mail.example.com");
}
}
function assertValidManagedSenderLocalPart(senderLocalPart: string) {
if (!/^[a-zA-Z0-9._%+-]+$/.test(senderLocalPart)) {
throw new StatusError(400, "sender_local_part is invalid");
}
}
function getManagedSenderEmail(subdomain: string, senderLocalPart: string) {
return `${senderLocalPart}@${subdomain}`;
}
function normalizeDomainName(name: string) {
return name.trim().toLowerCase().replace(/\.+$/, "");
}
function normalizeRecordName(name: string, zoneName: string) {
const normalizedName = normalizeDomainName(name);
const normalizedZoneName = normalizeDomainName(zoneName);
if (normalizedName === "@") {
return normalizedZoneName;
}
if (normalizedName === normalizedZoneName) {
return normalizedZoneName;
}
if (normalizedName.endsWith(`.${normalizedZoneName}`)) {
return normalizedName;
}
const zoneLabels = normalizedZoneName.split(".");
const zoneSubdomainLabel = zoneLabels[0];
if (zoneSubdomainLabel && normalizedName.endsWith(`.${zoneSubdomainLabel}`)) {
const recordWithoutZoneSubdomainLabel = normalizedName.slice(0, -(zoneSubdomainLabel.length + 1));
if (recordWithoutZoneSubdomainLabel.length > 0) {
return `${recordWithoutZoneSubdomainLabel}.${normalizedZoneName}`;
}
}
return `${normalizedName}.${normalizedZoneName}`;
}
function normalizeRecordContent(content: string) {
return content.trim().replace(/\.+$/, "");
}
async function parseJsonOrThrow<T>(response: Response, errorContext: string): Promise<T> {
if (!response.ok) {
const responseBody = await response.text();
throw new StackAssertionError(errorContext, {
status: response.status,
responseBody,
});
}
return await response.json() as T;
}
type DnsimpleResponse<T> = {
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<T>(response: Response, errorContext: string): Promise<T> {
const body = await parseJsonOrThrow<DnsimpleResponse<T>>(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<DnsimpleZone[]> {
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<DnsimpleZone[]>(
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<DnsimpleZone> {
const dnsimpleBaseUrl = getDnsimpleBaseUrl();
const dnsimpleAccountId = getDnsimpleAccountId();
const response = await fetch(`${dnsimpleBaseUrl}/${encodeURIComponent(dnsimpleAccountId)}/zones/${encodeURIComponent(zoneName)}`, {
method: "GET",
headers: getDnsimpleHeaders(),
});
return await parseDnsimpleJsonOrThrow<DnsimpleZone>(
response,
"Failed to fetch DNSimple zone details for managed email onboarding",
);
}
async function createDnsimpleZone(subdomain: string): Promise<DnsimpleZone> {
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<DnsimpleDomain>(
response,
"Failed to create DNSimple domain for managed email onboarding",
);
return {
id: domain.id,
name: domain.name,
};
}
async function createOrReuseDnsimpleZone(subdomain: string): Promise<DnsimpleZone> {
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<string[]> {
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<string>();
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<DnsimpleDnsRecord[]> {
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<DnsimpleDnsRecord[]>(
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<DnsimpleDnsRecord>(
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<ResendDomain> {
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<string> {
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<ManagedEmailSetupResult> {
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<ManagedEmailCheckResult> {
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<ManagedEmailListItem[]> {
const rows = await listManagedEmailDomainsForTenancy(options.tenancy.id);
return rows.map(managedDomainToListItem);
}
export async function applyManagedEmailProvider(options: {
tenancy: Tenancy,
domainId: string,
}): Promise<ManagedEmailApplyResult> {
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,
},
});
}
+2
View File
@@ -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 =======================
@@ -164,11 +164,15 @@ function EmulatorModeCard() {
function EmailServerCard({ emailConfig }: { emailConfig: CompleteConfig['emails']['server'] }) {
const serverType = emailConfig.isShared
? 'Shared'
: (emailConfig.provider === 'resend' ? 'Resend' : 'Custom SMTP');
: emailConfig.provider === 'managed'
? 'Managed By Stack Auth'
: (emailConfig.provider === 'resend' ? 'Resend' : 'Custom SMTP');
const senderEmail = emailConfig.isShared
? '[email protected]'
: emailConfig.senderEmail;
: emailConfig.provider === 'managed' && emailConfig.managedSubdomain && emailConfig.managedSenderLocalPart
? `${emailConfig.managedSenderLocalPart}@${emailConfig.managedSubdomain}`
: emailConfig.senderEmail;
return (
<GlassCard gradientColor="slate">
@@ -191,6 +195,14 @@ function EmailServerCard({ emailConfig }: { emailConfig: CompleteConfig['emails'
}
/>
)}
<ManagedEmailSetupDialog
trigger={
<Button variant='ghost' size="sm" className="h-8 px-3 text-xs gap-1.5">
<Envelope className="h-3.5 w-3.5" />
Managed Setup
</Button>
}
/>
<EditEmailServerDialog
trigger={
<Button variant='secondary' size="sm" className="h-8 px-3 text-xs gap-1.5">
@@ -224,12 +236,235 @@ function EmailServerCard({ emailConfig }: { emailConfig: CompleteConfig['emails'
</span>
<span className="text-sm font-medium text-foreground font-mono">{senderEmail}</span>
</div>
{emailConfig.provider === "managed" && (
<div className="flex flex-col gap-2">
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Managed Domain
</span>
<span className="text-sm font-medium text-foreground font-mono">{emailConfig.managedSubdomain}</span>
</div>
)}
{emailConfig.provider === "managed" && (
<div className="flex flex-col gap-2">
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Sender Local Part
</span>
<span className="text-sm font-medium text-foreground font-mono">{emailConfig.managedSenderLocalPart}</span>
</div>
)}
</div>
</div>
</GlassCard>
);
}
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<Array<{
domainId: string,
subdomain: string,
senderLocalPart: string,
status: "pending_dns" | "pending_verification" | "verified" | "applied" | "failed",
nameServerRecords: string[],
}>>([]);
const [error, setError] = useState<string | null>(null);
const [loadingDomains, setLoadingDomains] = useState(false);
const refreshDomains = async () => {
setLoadingDomains(true);
try {
const result = await stackAdminApp.listManagedEmailDomains();
setDomains(result);
} finally {
setLoadingDomains(false);
}
};
return (
<FormDialog
trigger={props.trigger}
open={open}
onOpenChange={(newOpen) => {
setOpen(newOpen);
if (newOpen) {
runAsynchronouslyWithAlert(async () => {
await refreshDomains();
}, {
onError: (err) => {
setError(err instanceof Error ? err.message : "Failed to load managed domains");
},
});
} else {
setSetupState(null);
setDomains([]);
setError(null);
}
}}
title="Managed Email Setup"
formSchema={managedEmailSetupSchema}
defaultValues={{ subdomain: "", senderLocalPart: "updates" }}
okButton={setupState ? false : { label: "Start Setup" }}
cancelButton
onSubmit={async (values) => {
const setupResult = await stackAdminApp.setupManagedEmailProvider({
subdomain: values.subdomain,
senderLocalPart: values.senderLocalPart,
});
setSetupState({
domainId: setupResult.domainId,
nameServerRecords: setupResult.nameServerRecords,
subdomain: setupResult.subdomain,
senderLocalPart: setupResult.senderLocalPart,
status: setupResult.status,
});
await refreshDomains();
setError(null);
return "prevent-close" as const;
}}
render={(form) => (
<>
{!setupState && (
<>
<InputField
label="Managed subdomain"
name="subdomain"
control={form.control}
type="text"
placeholder="emails.example.com"
required
/>
<InputField
label="Sender local part"
name="senderLocalPart"
control={form.control}
type="text"
required
/>
</>
)}
{setupState && (
<Alert className="bg-blue-500/5 border-blue-500/20">
<AlertTitle>Delegate your subdomain with these NS records</AlertTitle>
<AlertDescription>
Add these nameservers at your DNS provider for the managed subdomain you entered.
<div className="mt-2 flex flex-col gap-1">
{setupState.nameServerRecords.map((record) => (
<div key={record} className="font-mono text-xs">{record}</div>
))}
</div>
</AlertDescription>
</Alert>
)}
{setupState && (
<div className="flex items-center gap-2">
<Button
variant="secondary"
onClick={() => runAsynchronouslyWithAlert(async () => {
const result = await stackAdminApp.checkManagedEmailStatus({
domainId: setupState.domainId,
subdomain: setupState.subdomain,
senderLocalPart: setupState.senderLocalPart,
});
setSetupState({
...setupState,
status: result.status,
});
await refreshDomains();
})}
>
Refresh Status
</Button>
<Button
variant="default"
disabled={setupState.status !== "verified"}
onClick={() => runAsynchronouslyWithAlert(async () => {
await stackAdminApp.applyManagedEmailProvider({
domainId: setupState.domainId,
});
setOpen(false);
})}
>
Use This Domain
</Button>
</div>
)}
{(() => {
const visibleDomains = setupState ? domains.filter((d) => d.domainId === setupState.domainId) : domains;
return <div className="space-y-2">
<Typography variant="secondary" className="text-xs uppercase tracking-wider">Tracked managed domains</Typography>
{loadingDomains ? (
<Typography variant="secondary" className="text-sm">Loading managed domains...</Typography>
) : visibleDomains.length === 0 ? (
<Typography variant="secondary" className="text-sm">No managed domains tracked yet.</Typography>
) : (
visibleDomains.map((domain) => (
<Alert key={domain.domainId} className="bg-slate-500/5 border-slate-500/20">
<AlertTitle className="font-mono text-xs">{domain.senderLocalPart}@{domain.subdomain}</AlertTitle>
<AlertDescription className="mt-1 flex items-center justify-between gap-2">
<span className="text-xs">Status: {domain.status}</span>
<Button
size="sm"
variant="secondary"
disabled={domain.status !== "verified"}
onClick={() => runAsynchronouslyWithAlert(async () => {
await stackAdminApp.applyManagedEmailProvider({
domainId: domain.domainId,
});
await refreshDomains();
})}
>
Use This Domain
</Button>
</AlertDescription>
</Alert>
))
)}
</div>;
})()}
{error && <Alert variant="destructive">{error}</Alert>}
</>
)}
/>
);
}
function EmailLogCard() {
const stackAdminApp = useAdminApp();
const [emailLogs, setEmailLogs] = useState<AdminSentEmail[]>([]);
@@ -392,6 +627,16 @@ const getDefaultValues = (emailConfig: CompleteConfig['emails']['server'] | unde
senderName: emailConfig.senderName,
password: emailConfig.password,
} as const;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (emailConfig.provider === 'managed') {
return {
type: 'managed',
senderEmail: emailConfig.managedSubdomain && emailConfig.managedSenderLocalPart
? `${emailConfig.managedSenderLocalPart}@${emailConfig.managedSubdomain}`
: emailConfig.senderEmail,
senderName: emailConfig.senderName ?? project.displayName,
password: emailConfig.password,
} as const;
} else {
return {
type: 'standard',
@@ -406,7 +651,7 @@ const getDefaultValues = (emailConfig: CompleteConfig['emails']['server'] | unde
};
const emailServerSchema = yup.object({
type: yup.string().oneOf(['shared', 'standard', 'resend']).defined(),
type: yup.string().oneOf(['shared', 'standard', 'resend', 'managed']).defined(),
host: definedWhenTypeIsOneOf(yup.string(), ["standard"], "Host is required"),
port: definedWhenTypeIsOneOf(yup.number().min(0, "Port must be a number between 0 and 65535").max(65535, "Port must be a number between 0 and 65535"), ["standard"], "Port is required"),
username: definedWhenTypeIsOneOf(yup.string(), ["standard"], "Username is required"),
@@ -468,7 +713,7 @@ function InputFieldWithInfo({
name={name}
control={control}
type={type}
// Don't pass required prop - it adds asterisk which we don't want
// Don't pass required prop - it adds asterisk which we don't want
/>
);
}
@@ -508,6 +753,8 @@ function EditEmailServerDialog(props: {
senderEmail: emailConfig.senderEmail,
senderName: emailConfig.senderName,
provider: emailConfig.type === 'resend' ? 'resend' : 'smtp',
managedSubdomain: undefined,
managedSenderLocalPart: undefined,
} satisfies CompleteConfig['emails']['server']
},
pushable: false,
@@ -540,6 +787,13 @@ function EditEmailServerDialog(props: {
},
pushable: false,
});
} else if (values.type === 'managed') {
// Managed config is set through the ManagedEmailSetupDialog; just close
toast({
title: "Email server unchanged",
description: "Managed email configuration is controlled through the managed domain setup.",
variant: 'success',
});
} else if (values.type === 'resend') {
if (!values.password || !values.senderEmail || !values.senderName) {
throwErr("Missing email server config for Resend");
@@ -584,6 +838,7 @@ function EditEmailServerDialog(props: {
control={form.control}
options={[
{ label: "Shared ([email protected])", value: 'shared' },
{ label: "Managed (via managed domain setup)", value: 'managed' },
{ label: "Resend (your own email address)", value: 'resend' },
{ label: "Custom SMTP server (your own email address)", value: 'standard' },
]}
@@ -618,6 +873,21 @@ function EditEmailServerDialog(props: {
</Typography>
</Alert>
</>}
{form.watch('type') === 'managed' && <>
<Alert className="bg-blue-500/5 border-blue-500/20">
<AlertTitle>Managed Email Domain</AlertTitle>
<AlertDescription>
<Typography variant="secondary" className="text-sm">
This email server was configured through the managed domain setup flow. To change the domain or sender, use the managed email setup dialog above.
</Typography>
{defaultValues.type === 'managed' && defaultValues.senderEmail && (
<Typography variant="secondary" className="text-sm mt-2">
<strong>Sender:</strong> {defaultValues.senderName ? `${defaultValues.senderName} <${defaultValues.senderEmail}>` : defaultValues.senderEmail}
</Typography>
)}
</AlertDescription>
</Alert>
</>}
{form.watch('type') === 'standard' && <>
<InputFieldWithInfo
label="Host"
@@ -707,9 +977,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 +991,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"),
+22 -20
View File
@@ -17,6 +17,16 @@ export function SmartFormDialog<S extends yup.ObjectSchema<any, any, any, any>>(
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<S>) => {
const res = await props.onSubmit(values);
if (res !== 'prevent-close') {
@@ -34,16 +44,7 @@ export function SmartFormDialog<S extends yup.ObjectSchema<any, any, any, any>>(
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}
>
<SmartForm formSchema={props.formSchema} onSubmit={handleSubmit} onChangeIsSubmitting={setSubmitting} formId={formId} />
</ActionDialog>
@@ -67,6 +68,16 @@ export function FormDialog<F extends FieldValues>(
});
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<F extends FieldValues>(
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}
>
<Form {...(form)}>
<form
@@ -0,0 +1,122 @@
import { describe } from "vitest";
import { it } from "../../../../../helpers";
import { Project, niceBackendFetch } from "../../../../backend-helpers";
describe("managed email onboarding internal endpoints", () => {
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",
<some fields may have been hidden>,
},
}
`);
});
it("sets up managed onboarding, exposes status, and applies only with explicit action", async ({ expect }) => {
await Project.createAndSwitch();
const setupResponse = await niceBackendFetch("/api/v1/internal/emails/managed-onboarding/setup", {
method: "POST",
accessType: "admin",
body: {
subdomain: "mail.example.com",
sender_local_part: "noreply",
},
});
expect(setupResponse.status).toBe(200);
expect(setupResponse.body.domain_id).toBeDefined();
expect(setupResponse.body.status).toBe("verified");
const listResponse = await niceBackendFetch("/api/v1/internal/emails/managed-onboarding/list", {
method: "GET",
accessType: "admin",
});
expect(listResponse.body.items).toHaveLength(1);
expect(listResponse.body.items[0]).toMatchObject({
domain_id: setupResponse.body.domain_id,
subdomain: "mail.example.com",
sender_local_part: "noreply",
status: "verified",
name_server_records: ["ns1.dnsimple.com", "ns2.dnsimple.com"],
});
const checkResponse = await niceBackendFetch("/api/v1/internal/emails/managed-onboarding/check", {
method: "POST",
accessType: "admin",
body: {
domain_id: setupResponse.body.domain_id,
subdomain: "mail.example.com",
sender_local_part: "noreply",
},
});
expect(checkResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "status": "verified" },
"headers": Headers { <some fields may have been hidden> },
}
`);
const configBeforeApply = await niceBackendFetch("/api/v1/internal/config", {
method: "GET",
accessType: "admin",
});
const configBefore = JSON.parse(configBeforeApply.body.config_string);
expect(configBefore.emails.server.provider).not.toBe("managed");
const applyResponse = await niceBackendFetch("/api/v1/internal/emails/managed-onboarding/apply", {
method: "POST",
accessType: "admin",
body: {
domain_id: setupResponse.body.domain_id,
},
});
expect(applyResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "status": "applied" },
"headers": Headers { <some fields may have been hidden> },
}
`);
const configResponse = await niceBackendFetch("/api/v1/internal/config", {
method: "GET",
accessType: "admin",
});
const config = JSON.parse(configResponse.body.config_string);
expect(config.emails.server).toMatchObject({
isShared: false,
provider: "managed",
managedSubdomain: "mail.example.com",
managedSenderLocalPart: "noreply",
senderEmail: "[email protected]",
});
expect(config.emails.server.password).toEqual(expect.stringMatching(/^managed_mock_key_/));
});
});