mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
emails managed provider
This commit is contained in:
parent
8052a2be62
commit
9854ce2f74
@ -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
|
||||
|
||||
|
||||
@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
184
apps/backend/src/lib/managed-email-onboarding.test.tsx
Normal file
184
apps/backend/src/lib/managed-email-onboarding.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
496
apps/backend/src/lib/managed-email-onboarding.tsx
Normal file
496
apps/backend/src/lib/managed-email-onboarding.tsx
Normal 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"],
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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 =======================
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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,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_/));
|
||||
});
|
||||
});
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
45
docs/plans/resend-email-onboarding-dnsimple-condensed.md
Normal file
45
docs/plans/resend-email-onboarding-dnsimple-condensed.md
Normal 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.
|
||||
@ -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: [{
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 }>,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user