emails managed provider

This commit is contained in:
Bilal Godil 2026-02-23 20:04:08 -08:00
parent 8052a2be62
commit 9854ce2f74
19 changed files with 1337 additions and 33 deletions

View File

@ -95,3 +95,7 @@ STACK_CLICKHOUSE_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}36
STACK_CLICKHOUSE_ADMIN_USER=stackframe
STACK_CLICKHOUSE_ADMIN_PASSWORD=PASSWORD-PLACEHOLDER--9gKyMxJeMx
STACK_CLICKHOUSE_EXTERNAL_PASSWORD=PASSWORD-PLACEHOLDER--EZeHscBMzE
# Managed emails
STACK_RESEND_API_KEY=mock_resend_api_key

View File

@ -0,0 +1,46 @@
import { checkManagedEmailProviderStatus } from "@/lib/managed-email-onboarding";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
auth: yupObject({
type: adminAuthTypeSchema.defined(),
tenancy: adaptSchema.defined(),
}).defined(),
body: yupObject({
domain_id: yupString().defined(),
subdomain: yupString().defined(),
sender_local_part: yupString().defined(),
}).defined(),
method: yupString().oneOf(["POST"]).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
status: yupString().oneOf(["pending", "complete"]).defined(),
missing_name_server_records: yupArray(yupString().defined()).optional(),
}).defined(),
}),
handler: async ({ auth, body }) => {
const checkResult = await checkManagedEmailProviderStatus({
tenancy: auth.tenancy,
domainId: body.domain_id,
subdomain: body.subdomain,
senderLocalPart: body.sender_local_part,
});
return {
statusCode: 200,
bodyType: "json",
body: {
status: checkResult.status,
...(checkResult.status === "pending" ? { missing_name_server_records: checkResult.missingNameServerRecords } : {}),
},
};
},
});

View File

@ -0,0 +1,44 @@
import { setupManagedEmailProvider } from "@/lib/managed-email-onboarding";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
auth: yupObject({
type: adminAuthTypeSchema.defined(),
tenancy: adaptSchema.defined(),
}).defined(),
body: yupObject({
subdomain: yupString().defined(),
sender_local_part: yupString().defined(),
}).defined(),
method: yupString().oneOf(["POST"]).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
domain_id: yupString().defined(),
name_server_records: yupArray(yupString().defined()).defined(),
}).defined(),
}),
handler: async ({ auth, body }) => {
const setupResult = await setupManagedEmailProvider({
subdomain: body.subdomain,
senderLocalPart: body.sender_local_part,
tenancyId: auth.tenancy.id,
});
return {
statusCode: 200,
bodyType: "json",
body: {
domain_id: setupResult.domainId,
name_server_records: setupResult.nameServerRecords,
},
};
},
});

View File

@ -1084,6 +1084,16 @@ export const renderedOrganizationConfigToProjectCrud = (renderedConfig: Complete
email_config: renderedConfig.emails.server.isShared ? {
type: 'shared',
} : renderedConfig.emails.server.provider === "managed" ? {
type: 'standard',
host: "smtp.resend.com",
port: 465,
username: "resend",
password: renderedConfig.emails.server.password,
sender_name: renderedConfig.emails.server.senderName,
sender_email: renderedConfig.emails.server.managedSubdomain && renderedConfig.emails.server.managedSenderLocalPart
? `${renderedConfig.emails.server.managedSenderLocalPart}@${renderedConfig.emails.server.managedSubdomain}`
: renderedConfig.emails.server.senderEmail,
} : {
type: 'standard',
host: renderedConfig.emails.server.host,

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 });
}

View File

@ -0,0 +1,184 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { setupManagedEmailProvider } from "./managed-email-onboarding";
function jsonResponse(body: unknown, status: number = 200) {
return new Response(JSON.stringify(body), {
status,
headers: {
"content-type": "application/json",
},
});
}
function setupCommonEnv() {
vi.stubEnv("NODE_ENV", "development");
vi.stubEnv("STACK_RESEND_API_KEY", "resend_test_key");
vi.stubEnv("STACK_CLOUDFLARE_API_TOKEN", "cf_test_token");
vi.stubEnv("STACK_CLOUDFLARE_ACCOUNT_ID", "cf_account_123");
vi.stubEnv("STACK_CLOUDFLARE_API_BASE_URL", "https://api.cloudflare.test/client/v4");
}
describe("setupManagedEmailProvider with Cloudflare delegation", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it("creates resend records in an existing Cloudflare zone and returns Cloudflare nameservers", async () => {
setupCommonEnv();
const fetchSpy = vi.spyOn(globalThis, "fetch");
const responses: Response[] = [
jsonResponse({
id: "resend_domain_123",
name: "mail.customer-example.com",
records: [
{ record: "TXT", type: "TXT", name: "mail.customer-example.com", value: "v=spf1 include:amazonses.com ~all", status: "pending" },
{ record: "CNAME", type: "CNAME", name: "em.mail.customer-example.com", value: "u123.wl.sendgrid.net", status: "pending" },
{ record: "MX", type: "MX", name: "mail.customer-example.com", value: "feedback-smtp.us-east-1.amazonses.com", priority: 10, status: "pending" },
{ record: "NS", type: "NS", name: "mail.customer-example.com", value: "ignored.ns.example.com", status: "pending" },
],
}),
jsonResponse({
success: true,
errors: [],
result: [
{
id: "zone_123",
name: "mail.customer-example.com",
name_servers: ["alex.ns.cloudflare.com", "jamie.ns.cloudflare.com"],
},
],
}),
jsonResponse({
success: true,
errors: [],
result: [],
}),
jsonResponse({
success: true,
errors: [],
result: {
id: "record_txt_1",
type: "TXT",
name: "mail.customer-example.com",
content: "v=spf1 include:amazonses.com ~all",
},
}),
jsonResponse({
success: true,
errors: [],
result: {
id: "record_cname_1",
type: "CNAME",
name: "em.mail.customer-example.com",
content: "u123.wl.sendgrid.net",
},
}),
jsonResponse({
success: true,
errors: [],
result: {
id: "record_mx_1",
type: "MX",
name: "mail.customer-example.com",
content: "feedback-smtp.us-east-1.amazonses.com",
priority: 10,
},
}),
];
fetchSpy.mockImplementation(async () => {
const response = responses.shift();
if (response == null) {
throw new Error("Unexpected fetch call in managed-email-onboarding test");
}
return response;
});
const result = await setupManagedEmailProvider({
subdomain: "mail.customer-example.com",
senderLocalPart: "noreply",
tenancyId: "tenancy_123",
});
expect(result).toEqual({
domainId: "resend_domain_123",
nameServerRecords: ["alex.ns.cloudflare.com", "jamie.ns.cloudflare.com"],
});
const createRecordCalls = fetchSpy.mock.calls
.filter((call) => typeof call[0] === "string" && call[0].toString().includes("/dns_records") && call[1]?.method === "POST");
expect(createRecordCalls).toHaveLength(3);
});
it("fails loudly when a CNAME would conflict with existing DNS records", async () => {
setupCommonEnv();
const fetchSpy = vi.spyOn(globalThis, "fetch");
const responses: Response[] = [
jsonResponse({
id: "resend_domain_456",
name: "mail.customer-two.com",
records: [
{ record: "CNAME", type: "CNAME", name: "mail.customer-two.com", value: "u456.wl.sendgrid.net", status: "pending" },
],
}),
jsonResponse({
success: true,
errors: [],
result: [
{
id: "zone_456",
name: "mail.customer-two.com",
name_servers: ["alex.ns.cloudflare.com", "jamie.ns.cloudflare.com"],
},
],
}),
jsonResponse({
success: true,
errors: [],
result: [
{
id: "existing_txt",
type: "TXT",
name: "mail.customer-two.com",
content: "v=spf1 include:amazonses.com ~all",
},
],
}),
];
fetchSpy.mockImplementation(async () => {
const response = responses.shift();
if (response == null) {
throw new Error("Unexpected fetch call in managed-email-onboarding conflict test");
}
return response;
});
await expect(setupManagedEmailProvider({
subdomain: "mail.customer-two.com",
senderLocalPart: "noreply",
tenancyId: "tenancy_456",
})).rejects.toThrowError("Cannot create Cloudflare DNS record because of CNAME conflict");
});
it("uses mock onboarding automatically in development when resend key is a mock key", async () => {
vi.stubEnv("NODE_ENV", "development");
vi.stubEnv("STACK_RESEND_API_KEY", "mock_resend_api_key");
const fetchSpy = vi.spyOn(globalThis, "fetch");
const result = await setupManagedEmailProvider({
subdomain: "mail.customer-three.com",
senderLocalPart: "noreply",
tenancyId: "tenancy_789",
});
expect(result).toEqual({
domainId: "managed_mock_tenancy_789_mail_customer-three_com",
nameServerRecords: ["alex.ns.cloudflare.com", "jamie.ns.cloudflare.com"],
});
expect(fetchSpy).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,496 @@
import { overrideEnvironmentConfigOverride } from "@/lib/config";
import { Tenancy } from "@/lib/tenancies";
import { getNodeEnvironment, getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
type ResendDomainRecord = {
record: string,
name: string,
type: string,
value: string,
status: string,
priority?: number,
};
type ResendDomain = {
id: string,
name: string,
status?: string,
records?: ResendDomainRecord[],
};
type ManagedEmailSetupResult = {
domainId: string,
nameServerRecords: string[],
};
type ManagedEmailCheckResult =
| { status: "pending", missingNameServerRecords: string[] }
| { status: "complete" };
function shouldUseMockManagedEmailOnboarding() {
const nodeEnvironment = getNodeEnvironment();
if (nodeEnvironment === "test") {
return true;
}
if (nodeEnvironment === "development") {
const resendApiKey = getEnvVariable("STACK_RESEND_API_KEY", "");
if (resendApiKey.startsWith("mock_")) {
return true;
}
}
return false;
}
function assertValidManagedSubdomain(subdomain: string) {
if (!/^[a-zA-Z0-9.-]+$/.test(subdomain) || !subdomain.includes(".")) {
throw new StatusError(400, "subdomain must be a fully-qualified domain name like mail.example.com");
}
}
function assertValidManagedSenderLocalPart(senderLocalPart: string) {
if (!/^[a-zA-Z0-9._%+-]+$/.test(senderLocalPart)) {
throw new StatusError(400, "sender_local_part is invalid");
}
}
function getManagedSenderEmail(subdomain: string, senderLocalPart: string) {
return `${senderLocalPart}@${subdomain}`;
}
function normalizeDomainName(name: string) {
return name.trim().toLowerCase().replace(/\.+$/, "");
}
function normalizeRecordName(name: string, zoneName: string) {
const normalizedName = normalizeDomainName(name);
const normalizedZoneName = normalizeDomainName(zoneName);
if (normalizedName === "@") {
return normalizedZoneName;
}
if (normalizedName === normalizedZoneName) {
return normalizedZoneName;
}
return normalizedName.endsWith(`.${normalizedZoneName}`)
? normalizedName
: `${normalizedName}.${normalizedZoneName}`;
}
function normalizeRecordContent(content: string) {
return content.trim().replace(/\.+$/, "");
}
async function parseJsonOrThrow<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 CloudflareApiError = {
code?: number,
message?: string,
};
type CloudflareApiResponse<T> = {
success?: boolean,
errors?: CloudflareApiError[],
result?: T,
};
type CloudflareZone = {
id: string,
name: string,
name_servers?: string[],
};
type CloudflareDnsRecord = {
id: string,
type: string,
name: string,
content: string,
priority?: number,
};
async function parseCloudflareJsonOrThrow<T>(response: Response, errorContext: string): Promise<T> {
const body = await parseJsonOrThrow<CloudflareApiResponse<T>>(response, errorContext);
if (!body.success || !body.result) {
throw new StackAssertionError(errorContext, {
cloudflareErrors: body.errors,
cloudflareSuccess: body.success,
});
}
return body.result;
}
function getCloudflareBaseUrl() {
return getEnvVariable("STACK_CLOUDFLARE_API_BASE_URL", "https://api.cloudflare.com/client/v4");
}
function getCloudflareHeaders() {
return {
"Authorization": `Bearer ${getEnvVariable("STACK_CLOUDFLARE_API_TOKEN")}`,
"Content-Type": "application/json",
"Accept": "application/json",
};
}
async function listCloudflareZones(subdomain: string): Promise<CloudflareZone[]> {
const cloudflareBaseUrl = getCloudflareBaseUrl();
const cloudflareAccountId = getEnvVariable("STACK_CLOUDFLARE_ACCOUNT_ID");
const response = await fetch(`${cloudflareBaseUrl}/zones?name=${encodeURIComponent(subdomain)}&account.id=${encodeURIComponent(cloudflareAccountId)}&page=1&per_page=50`, {
method: "GET",
headers: getCloudflareHeaders(),
});
const zones = await parseCloudflareJsonOrThrow<CloudflareZone[]>(
response,
"Failed to list Cloudflare zones for managed email onboarding",
);
return zones.filter((zone) => normalizeDomainName(zone.name) === normalizeDomainName(subdomain));
}
async function getCloudflareZoneById(zoneId: string): Promise<CloudflareZone> {
const cloudflareBaseUrl = getCloudflareBaseUrl();
const response = await fetch(`${cloudflareBaseUrl}/zones/${encodeURIComponent(zoneId)}`, {
method: "GET",
headers: getCloudflareHeaders(),
});
return await parseCloudflareJsonOrThrow<CloudflareZone>(
response,
"Failed to fetch Cloudflare zone details for managed email onboarding",
);
}
async function createCloudflareZone(subdomain: string): Promise<CloudflareZone> {
const cloudflareBaseUrl = getCloudflareBaseUrl();
const cloudflareAccountId = getEnvVariable("STACK_CLOUDFLARE_ACCOUNT_ID");
const response = await fetch(`${cloudflareBaseUrl}/zones`, {
method: "POST",
headers: getCloudflareHeaders(),
body: JSON.stringify({
account: {
id: cloudflareAccountId,
},
name: normalizeDomainName(subdomain),
type: "full",
jump_start: false,
}),
});
return await parseCloudflareJsonOrThrow<CloudflareZone>(
response,
"Failed to create Cloudflare zone for managed email onboarding",
);
}
async function createOrReuseCloudflareZone(subdomain: string): Promise<CloudflareZone> {
const existingZones = await listCloudflareZones(subdomain);
if (existingZones.length > 1) {
throw new StackAssertionError("Multiple Cloudflare zones found for managed email onboarding subdomain", {
subdomain,
zoneIds: existingZones.map((zone) => zone.id),
});
}
const zone = existingZones[0] ?? await createCloudflareZone(subdomain);
if (zone.name_servers?.length) {
return zone;
}
return await getCloudflareZoneById(zone.id);
}
async function listCloudflareDnsRecords(zoneId: string): Promise<CloudflareDnsRecord[]> {
const cloudflareBaseUrl = getCloudflareBaseUrl();
const response = await fetch(`${cloudflareBaseUrl}/zones/${encodeURIComponent(zoneId)}/dns_records?page=1&per_page=5000`, {
method: "GET",
headers: getCloudflareHeaders(),
});
return await parseCloudflareJsonOrThrow<CloudflareDnsRecord[]>(
response,
"Failed to list Cloudflare DNS records for managed email onboarding",
);
}
async function createCloudflareDnsRecord(zoneId: string, record: {
type: string,
name: string,
content: string,
priority?: number,
}) {
const cloudflareBaseUrl = getCloudflareBaseUrl();
const response = await fetch(`${cloudflareBaseUrl}/zones/${encodeURIComponent(zoneId)}/dns_records`, {
method: "POST",
headers: getCloudflareHeaders(),
body: JSON.stringify({
type: record.type,
name: record.name,
content: record.content,
ttl: 1,
...(record.priority == null ? {} : { priority: record.priority }),
}),
});
await parseCloudflareJsonOrThrow<CloudflareDnsRecord>(
response,
"Failed to create Cloudflare DNS record for managed email onboarding",
);
}
type DesiredCloudflareDnsRecord = {
type: "TXT" | "CNAME" | "MX",
name: string,
content: string,
priority?: number,
};
function resendRecordToDesiredCloudflareRecord(record: ResendDomainRecord, subdomain: string): DesiredCloudflareDnsRecord | null {
const recordType = record.type.toUpperCase();
if (recordType !== "TXT" && recordType !== "CNAME" && recordType !== "MX") {
return null;
}
const normalizedName = normalizeRecordName(record.name, subdomain);
const normalizedContent = normalizeRecordContent(record.value);
if (!normalizedContent) {
return null;
}
return {
type: recordType,
name: normalizedName,
content: normalizedContent,
...(recordType === "MX" && record.priority != null ? { priority: record.priority } : {}),
};
}
function recordsEqual(existingRecord: CloudflareDnsRecord, desiredRecord: DesiredCloudflareDnsRecord) {
const sameName = normalizeDomainName(existingRecord.name) === normalizeDomainName(desiredRecord.name);
const sameType = existingRecord.type.toUpperCase() === desiredRecord.type;
const sameContent = normalizeRecordContent(existingRecord.content) === normalizeRecordContent(desiredRecord.content);
const samePriority = desiredRecord.type !== "MX" || (existingRecord.priority ?? null) === (desiredRecord.priority ?? null);
return sameName && sameType && sameContent && samePriority;
}
async function upsertCloudflareResendRecords(zoneId: string, subdomain: string, resendRecords: ResendDomainRecord[]) {
const existingRecords = await listCloudflareDnsRecords(zoneId);
const desiredRecords = resendRecords
.map((record) => resendRecordToDesiredCloudflareRecord(record, subdomain))
.filter((record): record is DesiredCloudflareDnsRecord => record != null);
for (const desiredRecord of desiredRecords) {
const recordsWithSameName = existingRecords.filter(
(existingRecord) => normalizeDomainName(existingRecord.name) === normalizeDomainName(desiredRecord.name),
);
const exactMatch = recordsWithSameName.find((existingRecord) => recordsEqual(existingRecord, desiredRecord));
if (exactMatch != null) {
continue;
}
const hasCnameConflict = recordsWithSameName.some((existingRecord) => {
const existingType = existingRecord.type.toUpperCase();
if (desiredRecord.type === "CNAME") {
return existingType !== "CNAME";
}
return existingType === "CNAME";
});
if (hasCnameConflict) {
throw new StackAssertionError("Cannot create Cloudflare DNS record because of CNAME conflict", {
zoneId,
desiredRecord,
});
}
if (desiredRecord.type === "CNAME" && recordsWithSameName.some((existingRecord) => existingRecord.type.toUpperCase() === "CNAME")) {
throw new StackAssertionError("Cloudflare CNAME record already exists with different content", {
zoneId,
desiredRecord,
existingRecords: recordsWithSameName,
});
}
await createCloudflareDnsRecord(zoneId, desiredRecord);
existingRecords.push({
id: `created-${desiredRecord.type}-${desiredRecord.name}-${desiredRecord.content}`,
type: desiredRecord.type,
name: desiredRecord.name,
content: desiredRecord.content,
priority: desiredRecord.priority,
});
}
}
async function createResendDomain(subdomain: string): Promise<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,
}),
});
const body = await parseJsonOrThrow<{ id: string, name: string, records?: ResendDomainRecord[] }>(
response,
"Failed to create Resend domain for managed email onboarding",
);
return {
id: body.id,
name: body.name,
records: body.records,
};
}
async function getResendDomain(domainId: string): Promise<ResendDomain> {
const resendApiKey = getEnvVariable("STACK_RESEND_API_KEY");
const response = await fetch(`https://api.resend.com/domains/${encodeURIComponent(domainId)}`, {
method: "GET",
headers: {
"Authorization": `Bearer ${resendApiKey}`,
"Content-Type": "application/json",
},
});
const body = await parseJsonOrThrow<{ id: string, name: string, status?: string, records?: ResendDomainRecord[] }>(
response,
"Failed to fetch Resend domain during managed email onboarding check",
);
return body;
}
async function createResendScopedKey(options: { subdomain: string, domainId: string, tenancyId: string }): Promise<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 during managed email onboarding check",
);
if (!body.token) {
throw new StackAssertionError("Resend did not return an API key token for managed onboarding", {
domainId: options.domainId,
tenancyId: options.tenancyId,
subdomain: options.subdomain,
});
}
return body.token;
}
export async function setupManagedEmailProvider(options: { subdomain: string, senderLocalPart: string, tenancyId: string }): Promise<ManagedEmailSetupResult> {
assertValidManagedSubdomain(options.subdomain);
assertValidManagedSenderLocalPart(options.senderLocalPart);
if (shouldUseMockManagedEmailOnboarding()) {
return {
domainId: `managed_mock_${options.tenancyId}_${options.subdomain}`.replace(/[^a-zA-Z0-9_-]/g, "_"),
nameServerRecords: ["alex.ns.cloudflare.com", "jamie.ns.cloudflare.com"],
};
}
const resendDomain = await createResendDomain(options.subdomain);
const cloudflareZone = await createOrReuseCloudflareZone(options.subdomain);
await upsertCloudflareResendRecords(cloudflareZone.id, options.subdomain, resendDomain.records ?? []);
const zoneNameServers = cloudflareZone.name_servers ?? [];
if (zoneNameServers.length === 0) {
throw new StackAssertionError("Cloudflare zone was created without nameservers for managed email onboarding", {
zoneId: cloudflareZone.id,
subdomain: options.subdomain,
});
}
return {
domainId: resendDomain.id,
nameServerRecords: zoneNameServers,
};
}
export async function checkManagedEmailProviderStatus(options: {
tenancy: Tenancy,
domainId: string,
subdomain: string,
senderLocalPart: string,
}): Promise<ManagedEmailCheckResult> {
assertValidManagedSubdomain(options.subdomain);
assertValidManagedSenderLocalPart(options.senderLocalPart);
if (shouldUseMockManagedEmailOnboarding()) {
const mockApiKey = `managed_mock_key_${options.tenancy.id}`;
await saveManagedEmailProviderConfig({
tenancy: options.tenancy,
resendApiKey: mockApiKey,
subdomain: options.subdomain,
senderLocalPart: options.senderLocalPart,
});
return { status: "complete" };
}
const resendDomain = await getResendDomain(options.domainId);
const notVerifiedRecords = resendDomain.records
?.filter((record) => record.record === "NS" || record.type === "NS")
.filter((record) => record.status !== "verified")
.map((record) => record.value)
.filter((value) => value.length > 0) ?? [];
if ((resendDomain.status !== "verified" && notVerifiedRecords.length > 0) || notVerifiedRecords.length > 0) {
return {
status: "pending",
missingNameServerRecords: notVerifiedRecords,
};
}
const resendApiKey = await createResendScopedKey({
subdomain: options.subdomain,
domainId: options.domainId,
tenancyId: options.tenancy.id,
});
await saveManagedEmailProviderConfig({
tenancy: options.tenancy,
resendApiKey,
subdomain: options.subdomain,
senderLocalPart: options.senderLocalPart,
});
return { status: "complete" };
}
async function saveManagedEmailProviderConfig(options: {
tenancy: Tenancy,
resendApiKey: string,
subdomain: string,
senderLocalPart: string,
}) {
await overrideEnvironmentConfigOverride({
projectId: options.tenancy.project.id,
branchId: options.tenancy.branchId,
environmentConfigOverrideOverride: {
"emails.server": {
isShared: false,
provider: "managed",
host: undefined,
port: undefined,
username: undefined,
password: options.resendApiKey,
senderName: options.tenancy.project.display_name,
senderEmail: getManagedSenderEmail(options.subdomain, options.senderLocalPart),
managedSubdomain: options.subdomain,
managedSenderLocalPart: options.senderLocalPart,
} as Tenancy["config"]["emails"]["server"],
},
});
}

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 =======================

View File

@ -13,7 +13,7 @@ import { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema";
import { strictEmailSchema } from "@stackframe/stack-shared/dist/schema-fields";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { deepPlainEquals } from "@stackframe/stack-shared/dist/utils/objects";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { runAsynchronouslyWithAlert, wait } from "@stackframe/stack-shared/dist/utils/promises";
import { ColumnDef, Table as TableType } from "@tanstack/react-table";
import { useEffect, useMemo, useState } from "react";
import * as yup from "yup";
@ -164,11 +164,15 @@ function EmulatorModeCard() {
function EmailServerCard({ emailConfig }: { emailConfig: CompleteConfig['emails']['server'] }) {
const serverType = emailConfig.isShared
? 'Shared'
: (emailConfig.provider === 'resend' ? 'Resend' : 'Custom SMTP');
: emailConfig.provider === 'managed'
? 'Managed Resend'
: (emailConfig.provider === 'resend' ? 'Resend' : 'Custom SMTP');
const senderEmail = emailConfig.isShared
? 'noreply@stackframe.co'
: emailConfig.senderEmail;
: emailConfig.provider === 'managed' && emailConfig.managedSubdomain && emailConfig.managedSenderLocalPart
? `${emailConfig.managedSenderLocalPart}@${emailConfig.managedSubdomain}`
: emailConfig.senderEmail;
return (
<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,192 @@ 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 { toast } = useToast();
const [open, setOpen] = useState(false);
const [setupState, setSetupState] = useState<{ domainId: string, nameServerRecords: string[], subdomain: string, senderLocalPart: string } | null>(null);
const [checkStatus, setCheckStatus] = useState<"idle" | "pending" | "complete">("idle");
const [missingNameServers, setMissingNameServers] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open || !setupState || checkStatus !== "pending") return;
let cancelled = false;
runAsynchronouslyWithAlert(async () => {
while (!cancelled) {
const result = await stackAdminApp.checkManagedEmailStatus({
domainId: setupState.domainId,
subdomain: setupState.subdomain,
senderLocalPart: setupState.senderLocalPart,
});
if (result.status === "complete") {
setCheckStatus("complete");
setMissingNameServers([]);
toast({
title: "Managed email enabled",
description: "Managed Resend provider is now configured for this project.",
variant: "success",
});
return;
}
setMissingNameServers(result.missingNameServerRecords);
await wait(3000);
}
}, {
onError: (err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : "Managed onboarding status check failed");
},
});
return () => {
cancelled = true;
};
}, [checkStatus, open, setupState, stackAdminApp, toast]);
return (
<FormDialog
trigger={props.trigger}
open={open}
onOpenChange={(newOpen) => {
setOpen(newOpen);
if (!newOpen) {
setSetupState(null);
setCheckStatus("idle");
setMissingNameServers([]);
setError(null);
}
}}
title="Managed Email Setup"
formSchema={managedEmailSetupSchema}
defaultValues={{ subdomain: "", senderLocalPart: "updates" }}
okButton={setupState ? false : { label: "Start Setup" }}
cancelButton
onSubmit={async (values) => {
const setupResult = await stackAdminApp.setupManagedEmailProvider({
subdomain: values.subdomain,
senderLocalPart: values.senderLocalPart,
});
setSetupState({
domainId: setupResult.domainId,
nameServerRecords: setupResult.nameServerRecords,
subdomain: values.subdomain,
senderLocalPart: values.senderLocalPart,
});
setError(null);
setMissingNameServers([]);
setCheckStatus("pending");
return "prevent-close" as const;
}}
render={(form) => (
<>
{setupState == null && (
<>
<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>
)}
{checkStatus === "pending" && (
<Alert className="bg-orange-500/5 border-orange-500/20">
<AlertTitle>Waiting for DNS propagation</AlertTitle>
<AlertDescription>
{missingNameServers.length > 0 ? `Missing NS records: ${missingNameServers.join(", ")}` : "Polling verification status..."}
</AlertDescription>
</Alert>
)}
{checkStatus === "complete" && (
<Alert className="bg-emerald-500/5 border-emerald-500/20">
<AlertTitle>Managed email provider is ready</AlertTitle>
</Alert>
)}
{error && <Alert variant="destructive">{error}</Alert>}
</>
)}
/>
);
}
function EmailLogCard() {
const stackAdminApp = useAdminApp();
const [emailLogs, setEmailLogs] = useState<AdminSentEmail[]>([]);
@ -392,6 +584,14 @@ const getDefaultValues = (emailConfig: CompleteConfig['emails']['server'] | unde
senderName: emailConfig.senderName,
password: emailConfig.password,
} as const;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (emailConfig.provider === 'managed') {
return {
type: 'resend',
senderEmail: emailConfig.managedSubdomain && emailConfig.managedSenderLocalPart ? `${emailConfig.managedSenderLocalPart}@${emailConfig.managedSubdomain}` : emailConfig.senderEmail,
senderName: emailConfig.senderName ?? project.displayName,
password: emailConfig.password,
} as const;
} else {
return {
type: 'standard',
@ -508,6 +708,8 @@ function EditEmailServerDialog(props: {
senderEmail: emailConfig.senderEmail,
senderName: emailConfig.senderName,
provider: emailConfig.type === 'resend' ? 'resend' : 'smtp',
managedSubdomain: undefined,
managedSenderLocalPart: undefined,
} satisfies CompleteConfig['emails']['server']
},
pushable: false,
@ -719,14 +921,24 @@ function TestSendingDialog(props: {
}
// Convert CompleteConfig email server to AdminEmailConfig format
const emailConfig: AdminEmailConfig = emailServerConfig.provider === 'resend' ? {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const emailConfig: AdminEmailConfig = emailServerConfig.provider === 'resend' || emailServerConfig.provider === 'managed' ? {
type: 'resend',
host: emailServerConfig.host ?? throwErr("Email host is missing"),
port: emailServerConfig.port ?? throwErr("Email port is missing"),
username: emailServerConfig.username ?? throwErr("Email username is missing"),
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
host: emailServerConfig.provider === "managed" ? "smtp.resend.com" : (emailServerConfig.host ?? throwErr("Email host is missing")),
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
port: emailServerConfig.provider === "managed" ? 465 : (emailServerConfig.port ?? throwErr("Email port is missing")),
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
username: emailServerConfig.provider === "managed" ? "resend" : (emailServerConfig.username ?? throwErr("Email username is missing")),
password: emailServerConfig.password ?? throwErr("Email password is missing"),
senderName: emailServerConfig.senderName ?? throwErr("Email sender name is missing"),
senderEmail: emailServerConfig.senderEmail ?? throwErr("Email sender email is missing"),
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
senderName: emailServerConfig.provider === "managed" ? project.displayName : (emailServerConfig.senderName ?? throwErr("Email sender name is missing")),
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
senderEmail: emailServerConfig.provider === "managed"
? (emailServerConfig.managedSubdomain && emailServerConfig.managedSenderLocalPart
? `${emailServerConfig.managedSenderLocalPart}@${emailServerConfig.managedSubdomain}`
: throwErr("Managed sender config is missing"))
: (emailServerConfig.senderEmail ?? throwErr("Email sender email is missing")),
} : {
type: 'standard',
host: emailServerConfig.host ?? throwErr("Email host is missing"),

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

View File

@ -0,0 +1,91 @@
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 and completes managed onboarding with admin access", 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.name_server_records).toMatchInlineSnapshot(`
[
"alex.ns.cloudflare.com",
"jamie.ns.cloudflare.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": "complete" },
"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: "noreply@mail.example.com",
});
expect(config.emails.server.password).toEqual(expect.stringMatching(/^managed_mock_key_/));
});
});

View File

@ -76,3 +76,18 @@ A: The card playground now includes a `Header Actions` toggle that injects a sam
Q: How should unsubscribe-link e2e tests avoid breakage from email theme/layout changes?
A: In `apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts`, avoid snapshotting the entire rendered HTML for transactional emails; assert stable behavior instead (email content present and `/api/v1/emails/unsubscribe-link` absent) so cosmetic wrapper/style changes do not fail the test.
Q: What is the intended managed email onboarding DNS flow with Cloudflare?
A: For a user-provided subdomain (for example `mail.customer.com`), create a dedicated Cloudflare zone for that exact subdomain, return that zone's nameservers to the user for NS delegation at their existing DNS provider, write Resend verification/sending records into the Cloudflare zone, and poll verification before persisting `emails.server.provider = "managed"` config.
Q: How can managed email onboarding run locally without real Resend/Cloudflare credentials?
A: In `apps/backend/src/lib/managed-email-onboarding.tsx`, mock onboarding is inferred (no separate flag): it auto-enables in test and in development when `STACK_RESEND_API_KEY` starts with `mock_` (for example `mock_resend_api_key`), so setup/check use mock nameservers and skip external fetches.
Q: Where is the managed email setup dialog on the dashboard emails page, and how should post-submit UI behave?
A: The dialog is `ManagedEmailSetupDialog` in `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx`; use a subdomain placeholder like `emails.example.com`, validate subdomain/local-part via schema before submit, and after setup starts hide both input fields and the submit button so users cannot re-run setup from that dialog state.
Q: Why can `okButton={false}` still show an OK button in dashboard form dialogs?
A: `FormDialog` and `SmartFormDialog` must explicitly pass `okButton={false}` through to `ActionDialog`; otherwise they can accidentally always construct a submit-button config, causing hidden-form submits and no-op clicks in post-submit states.
Q: What fields must be included when constructing `CompleteConfig['emails']['server']` objects after adding managed email support?
A: Even for non-managed providers (`smtp`/`resend`), include `managedSubdomain` and `managedSenderLocalPart` (typically `undefined`) to satisfy the widened `CompleteConfig['emails']['server']` type and keep typecheck green.

View File

@ -0,0 +1,48 @@
## New email managed provider setup
### `config/schema.ts` Changes
Update `packages/stack-shared/src/config/schema.ts` in `emails.server`:
1. Extend provider enum:
- from `oneOf(['resend', 'smtp'])`
- to `oneOf(['resend', 'smtp', 'managed'])`
2. Add managed metadata fields under `emails.server`:
- `managedSubdomain?: string` (e.g. `mail.example.com`)
- `managedSenderLocalPart?: string` (e.g. `noreply`)
3. Validation rules:
- When `provider === 'managed'` and `isShared === false`, require:
- `password` (created resend api key)
- plus all managed metadata fields above.
- For `provider !== 'managed'`, managed metadata fields optional/undefined.
4. Defaults:
- Keep default provider as `"smtp"` (existing behavior).
- Managed metadata defaults to `undefined`.
### Backend/API Plan
Add 2 internal routes:
1. `POST /api/latest/internal/emails/managed-onboarding/setup`
- Input: `subdomain`, `sender_local_part`
- Creates a dedicated Cloudflare zone for that exact subdomain
- Creates new domain in Resend and writes required DNS records into the Cloudflare zone
- Returns Cloudflare NS records for user to set at their existing DNS provider, plus `domainId`.
2. `POST /api/latest/internal/emails/managed-onboarding/check`
- Verifies that NS records are set. If not, return early
- Creates scoped Resend key.
- Writes `emails.server` with:
- `provider: "managed"`
- resend api key
- managed metadata fields.
### Dashboard / SDK
- Add 2 admin SDK methods (`checkManagedEmailStatus`, `setupManagedEmailProvider`).
- Emails page:
- managed setup button
- Opens dialog with inputs for subdomain, sender_local_part
- Calls setupManagedEmailProvider with values and shows user NS records to set
- Polls checkManagedEmailStatus until complete
- If current provider is `managed`, show stored subdomain + sender values from config.

View File

@ -0,0 +1,45 @@
# Resend Email Onboarding (Condensed)
## Goal
Auto-provision project-specific email infrastructure using Resend + Cloudflare delegated subzones, then store only project-specific credentials/metadata in config.
## Scope
- Trigger on project create only.
- Backend automation only (no new dashboard onboarding UI).
- Keep existing SMTP send pipeline, but infer fixed Resend SMTP fields for `managed`.
## Flow
1. Create Resend domain for project subdomain under `STACK_RESEND_BASE_DOMAIN`, notifications@<project-id>.<stack-auth-domain>
2. Create a Cloudflare zone for the exact customer subdomain and return Cloudflare nameservers.
3. User adds NS delegation for the subdomain at their DNS provider.
4. Create required DNS records in Cloudflare using Resend-provided records.
5. Verify domain with Resend (required).
6. Update `packages/stack-shared/src/config/schema.ts`:
- add a new email provider type: `managed`
- add a new property on `emails.server` for this provider: `domainProviderId`
7. Create Resend API key with `sending_access` for domain.
8. Persist config at `emails.server`:
- `isShared: false`
- `provider: "managed"`
- `password: <resend api key value>`
- `domainProviderId`
## Files to Add
- `apps/backend/src/lib/email-domain-provisioning.tsx`
## Files to Update
- `apps/backend/src/lib/projects.tsx` (hook provisioning into create path)
- `packages/stack-shared/src/config/schema.ts` (add new email provider type + keep only required Resend metadata fields + defaults)
- `apps/backend/src/lib/emails.tsx` (resolve fixed SMTP host/port/username for `managed`)
- `apps/backend/src/lib/config.tsx` (ensure compatibility on legacy transforms)
## New Env Vars
- `STACK_RESEND_API_KEY`
- `STACK_RESEND_BASE_DOMAIN`
- `STACK_CLOUDFLARE_API_TOKEN`
- `STACK_CLOUDFLARE_ACCOUNT_ID`
- `STACK_CLOUDFLARE_API_BASE_URL` (defaults to `https://api.cloudflare.com/client/v4`)
## Fallback/Failure Rules
- `development/test`: dev provisioning envs => fallback to shared email config.
- production: provisioning envs required; failures abort project creation.

View File

@ -232,13 +232,15 @@ const environmentSchemaFuzzerConfig = [{
...branchSchemaFuzzerConfig[0].emails[0],
server: [{
isShared: [true, false],
provider: ["resend", "smtp"] as const,
provider: ["resend", "smtp", "managed"] as const,
host: ["example.com", "://super weird host that's not valid"],
port: [1234, 0.12543, -100, Infinity],
username: ["some-username", "some username with a space"],
password: ["some-password", "some password with a space"],
senderName: ["Some Sender"],
senderEmail: ["some-sender@example.com", "some invalid email"],
managedSubdomain: ["mail.example.com", "invalid subdomain"],
managedSenderLocalPart: ["noreply", "some invalid local part"],
}],
}],
payments: [{

View File

@ -315,13 +315,27 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({
emails: branchConfigSchema.getNested("emails").concat(yupObject({
server: yupObject({
isShared: yupBoolean(),
provider: yupString().oneOf(['resend', 'smtp']).optional(),
provider: yupString().oneOf(['resend', 'smtp', 'managed']).optional(),
host: schemaFields.emailHostSchema.optional().nonEmpty(),
port: schemaFields.emailPortSchema.optional(),
username: schemaFields.emailUsernameSchema.optional().nonEmpty(),
password: schemaFields.emailPasswordSchema.optional().nonEmpty(),
password: schemaFields.emailPasswordSchema.optional().nonEmpty().when(['provider', 'isShared'], {
is: (provider: string | undefined, isShared: boolean) => provider === 'managed' && isShared === false,
then: (schema) => schema.defined("Password is required when using managed email provider"),
otherwise: (schema) => schema.optional(),
}),
senderName: schemaFields.emailSenderNameSchema.optional().nonEmpty(),
senderEmail: schemaFields.emailSenderEmailSchema.optional().nonEmpty(),
managedSubdomain: yupString().optional().nonEmpty().when(['provider', 'isShared'], {
is: (provider: string | undefined, isShared: boolean) => provider === 'managed' && isShared === false,
then: (schema) => schema.defined("Managed subdomain is required when using managed email provider"),
otherwise: (schema) => schema.optional(),
}),
managedSenderLocalPart: yupString().optional().nonEmpty().when(['provider', 'isShared'], {
is: (provider: string | undefined, isShared: boolean) => provider === 'managed' && isShared === false,
then: (schema) => schema.defined("Managed sender local part is required when using managed email provider"),
otherwise: (schema) => schema.optional(),
}),
}),
})),
@ -635,6 +649,8 @@ const organizationConfigDefaults = {
password: undefined,
senderName: undefined,
senderEmail: undefined,
managedSubdomain: undefined,
managedSenderLocalPart: undefined,
},
selectedThemeId: DEFAULT_EMAIL_THEME_ID,
themes: typedAssign((key: string) => ({

View File

@ -400,6 +400,35 @@ export class StackAdminInterface extends StackServerInterface {
return await response.json();
}
async setupManagedEmailProvider(data: {
subdomain: string,
sender_local_part: string,
}): Promise<{ domain_id: string, name_server_records: string[] }> {
const response = await this.sendAdminRequest("/internal/emails/managed-onboarding/setup", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(data),
}, null);
return await response.json();
}
async checkManagedEmailStatus(data: {
domain_id: string,
subdomain: string,
sender_local_part: string,
}): Promise<{ status: "pending" | "complete", missing_name_server_records?: string[] }> {
const response = await this.sendAdminRequest("/internal/emails/managed-onboarding/check", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(data),
}, null);
return await response.json();
}
async sendSignInInvitationEmail(
email: string,
callbackUrl: string,

View File

@ -20,7 +20,7 @@ import { InternalApiKey, InternalApiKeyBase, InternalApiKeyBaseCrudRead, Interna
import { AdminProjectPermission, AdminProjectPermissionDefinition, AdminProjectPermissionDefinitionCreateOptions, AdminProjectPermissionDefinitionUpdateOptions, AdminTeamPermission, AdminTeamPermissionDefinition, AdminTeamPermissionDefinitionCreateOptions, AdminTeamPermissionDefinitionUpdateOptions, adminProjectPermissionDefinitionCreateOptionsToCrud, adminProjectPermissionDefinitionUpdateOptionsToCrud, adminTeamPermissionDefinitionCreateOptionsToCrud, adminTeamPermissionDefinitionUpdateOptionsToCrud } from "../../permissions";
import { AdminOwnedProject, AdminProject, AdminProjectUpdateOptions, PushConfigOptions, adminProjectUpdateOptionsToCrud } from "../../projects";
import type { AdminSessionReplay, AdminSessionReplayChunk, ListSessionReplayChunksOptions, ListSessionReplayChunksResult, ListSessionReplaysOptions, ListSessionReplaysResult, SessionReplayAllEventsResult } from "../../session-replays";
import { StackAdminApp, StackAdminAppConstructorOptions } from "../interfaces/admin-app";
import { ManagedEmailProviderSetupResult, ManagedEmailProviderStatus, StackAdminApp, StackAdminAppConstructorOptions } from "../interfaces/admin-app";
import { clientVersion, createCache, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, getDefaultSuperSecretAdminKey, resolveConstructorOptions } from "./common";
import { _StackServerAppImplIncomplete } from "./server-app-impl";
@ -604,6 +604,34 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
}));
}
async setupManagedEmailProvider(options: { subdomain: string, senderLocalPart: string }): Promise<ManagedEmailProviderSetupResult> {
const response = await this._interface.setupManagedEmailProvider({
subdomain: options.subdomain,
sender_local_part: options.senderLocalPart,
});
return {
domainId: response.domain_id,
nameServerRecords: response.name_server_records,
};
}
async checkManagedEmailStatus(options: { domainId: string, subdomain: string, senderLocalPart: string }): Promise<ManagedEmailProviderStatus> {
const response = await this._interface.checkManagedEmailStatus({
domain_id: options.domainId,
subdomain: options.subdomain,
sender_local_part: options.senderLocalPart,
});
if (response.status === "pending") {
return {
status: "pending",
missingNameServerRecords: response.missing_name_server_records ?? [],
};
}
return {
status: "complete",
};
}
async sendSignInInvitationEmail(email: string, callbackUrl: string): Promise<void> {
await this._interface.sendSignInInvitationEmail(email, callbackUrl);
}

View File

@ -32,6 +32,15 @@ export type EmailOutboxUpdateOptions = {
cancel?: boolean,
};
export type ManagedEmailProviderSetupResult = {
domainId: string,
nameServerRecords: string[],
};
export type ManagedEmailProviderStatus =
| { status: "pending", missingNameServerRecords: string[] }
| { status: "complete" };
import type { ListSessionReplayChunksOptions, ListSessionReplayChunksResult, ListSessionReplaysOptions, ListSessionReplaysResult, SessionReplayAllEventsResult } from "../../session-replays";
export type { AdminSessionReplay, AdminSessionReplayChunk, ListSessionReplaysOptions, ListSessionReplaysResult, ListSessionReplayChunksOptions, ListSessionReplayChunksResult, SessionReplayAllEventsResult } from "../../session-replays";
@ -90,6 +99,8 @@ export type StackAdminApp<HasTokenStore extends boolean = boolean, ProjectId ext
sendSignInInvitationEmail(email: string, callbackUrl: string): Promise<void>,
listSentEmails(): Promise<AdminSentEmail[]>,
setupManagedEmailProvider(options: { subdomain: string, senderLocalPart: string }): Promise<ManagedEmailProviderSetupResult>,
checkManagedEmailStatus(options: { domainId: string, subdomain: string, senderLocalPart: string }): Promise<ManagedEmailProviderStatus>,
useEmailTheme(id: string): { displayName: string, tsxSource: string }, // THIS_LINE_PLATFORM react-like
createEmailTheme(displayName: string): Promise<{ id: string }>,