mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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
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:
@@ -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");
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 =======================
|
||||
|
||||
+295
-13
@@ -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"),
|
||||
|
||||
@@ -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_/));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user