Skip to content

Commit b701fdf

Browse files
authored
Managed email provider (#1222)
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Managed email domain onboarding: setup, DNS provisioning, verification, status checks, and apply flow (Resend-backed). * **UI** * Project email settings: managed-provider setup dialog, managed sender fields, status display, and test-send mapping. * **Integrations** * DNS provider automation and Resend webhook handling for domain status updates; scoped keys for sending. * **API** * Admin endpoints / client APIs to setup, check, list, and apply managed email domains. * **Tests** * End-to-end tests covering the full onboarding flow. * **Chores** * Added environment variables and config schema support for Resend and DNS integrations. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 57149bd commit b701fdf

File tree

21 files changed

+1867
-37
lines changed

21 files changed

+1867
-37
lines changed

apps/backend/.env.development

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,10 @@ STACK_CLICKHOUSE_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}36
9595
STACK_CLICKHOUSE_ADMIN_USER=stackframe
9696
STACK_CLICKHOUSE_ADMIN_PASSWORD=PASSWORD-PLACEHOLDER--9gKyMxJeMx
9797
STACK_CLICKHOUSE_EXTERNAL_PASSWORD=PASSWORD-PLACEHOLDER--EZeHscBMzE
98+
99+
# Managed emails
100+
STACK_RESEND_API_KEY=mock_resend_api_key
101+
STACK_RESEND_WEBHOOK_SECRET=mock_resend_webhook_secret
102+
STACK_DNSIMPLE_API_TOKEN=mock_dnsimple_api_token
103+
STACK_DNSIMPLE_ACCOUNT_ID=mock_dnsimple_account_id
104+
STACK_DNSIMPLE_API_BASE_URL=https://api.dnsimple.com/v2
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
CREATE TYPE "ManagedEmailDomainStatus" AS ENUM ('PENDING_DNS', 'PENDING_VERIFICATION', 'VERIFIED', 'APPLIED', 'FAILED');
2+
3+
CREATE TABLE "ManagedEmailDomain" (
4+
"id" UUID NOT NULL,
5+
"tenancyId" UUID NOT NULL,
6+
"projectId" TEXT NOT NULL,
7+
"branchId" TEXT NOT NULL,
8+
"subdomain" TEXT NOT NULL,
9+
"senderLocalPart" TEXT NOT NULL,
10+
"resendDomainId" TEXT NOT NULL,
11+
"nameServerRecords" JSONB NOT NULL,
12+
"status" "ManagedEmailDomainStatus" NOT NULL DEFAULT 'PENDING_VERIFICATION',
13+
"providerStatusRaw" TEXT,
14+
"isActive" BOOLEAN NOT NULL DEFAULT true,
15+
"lastError" TEXT,
16+
"verifiedAt" TIMESTAMP(3),
17+
"appliedAt" TIMESTAMP(3),
18+
"lastWebhookAt" TIMESTAMP(3),
19+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
20+
"updatedAt" TIMESTAMP(3) NOT NULL,
21+
22+
CONSTRAINT "ManagedEmailDomain_pkey" PRIMARY KEY ("id"),
23+
CONSTRAINT "ManagedEmailDomain_tenancyId_fkey" FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE
24+
);
25+
26+
CREATE UNIQUE INDEX "ManagedEmailDomain_resendDomainId_key" ON "ManagedEmailDomain"("resendDomainId");
27+
CREATE UNIQUE INDEX "ManagedEmailDomain_tenancyId_subdomain_key" ON "ManagedEmailDomain"("tenancyId", "subdomain");
28+
CREATE INDEX "ManagedEmailDomain_tenancy_status_active_idx" ON "ManagedEmailDomain"("tenancyId", "status", "isActive");

apps/backend/prisma/schema.prisma

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,49 @@ model Tenancy {
6262
emailOutboxes EmailOutbox[]
6363
sessionReplays SessionReplay[]
6464
sessionReplayChunks SessionReplayChunk[]
65+
managedEmailDomains ManagedEmailDomain[]
6566
6667
@@unique([projectId, branchId, organizationId])
6768
@@unique([projectId, branchId, hasNoOrganization])
6869
}
6970

71+
enum ManagedEmailDomainStatus {
72+
PENDING_DNS
73+
PENDING_VERIFICATION
74+
VERIFIED
75+
APPLIED
76+
FAILED
77+
}
78+
79+
model ManagedEmailDomain {
80+
id String @id @default(uuid()) @db.Uuid
81+
82+
tenancyId String @db.Uuid
83+
tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade)
84+
85+
projectId String
86+
branchId String
87+
88+
subdomain String
89+
senderLocalPart String
90+
resendDomainId String @unique
91+
nameServerRecords Json
92+
93+
status ManagedEmailDomainStatus @default(PENDING_VERIFICATION)
94+
providerStatusRaw String?
95+
isActive Boolean @default(true)
96+
lastError String?
97+
verifiedAt DateTime?
98+
appliedAt DateTime?
99+
lastWebhookAt DateTime?
100+
101+
createdAt DateTime @default(now())
102+
updatedAt DateTime @updatedAt
103+
104+
@@unique([tenancyId, subdomain])
105+
@@index([tenancyId, status, isActive], map: "ManagedEmailDomain_tenancy_status_active_idx")
106+
}
107+
70108
model BranchConfigOverride {
71109
projectId String
72110
branchId String
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { processResendDomainWebhookEvent } from "@/lib/managed-email-onboarding";
2+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
3+
import { yupBoolean, yupMixed, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields";
4+
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
5+
import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
6+
import { Result } from "@stackframe/stack-shared/dist/utils/results";
7+
import { Webhook } from "svix";
8+
9+
function decodeBody(bodyBuffer: ArrayBuffer) {
10+
return new TextDecoder().decode(bodyBuffer);
11+
}
12+
13+
function ensureResendWebhookSignature(headers: Record<string, string[] | undefined>, bodyBuffer: ArrayBuffer) {
14+
const webhookSecret = getEnvVariable("STACK_RESEND_WEBHOOK_SECRET");
15+
const svixId = headers["svix-id"]?.[0] ?? null;
16+
const svixTimestamp = headers["svix-timestamp"]?.[0] ?? null;
17+
const svixSignature = headers["svix-signature"]?.[0] ?? null;
18+
if (svixId == null || svixTimestamp == null || svixSignature == null) {
19+
throw new StatusError(400, "Missing Svix signature headers for Resend webhook");
20+
}
21+
22+
const verifier = new Webhook(webhookSecret);
23+
const result = Result.fromThrowing(() => verifier.verify(decodeBody(bodyBuffer), {
24+
"svix-id": svixId,
25+
"svix-timestamp": svixTimestamp,
26+
"svix-signature": svixSignature,
27+
}));
28+
if (result.status === "error") {
29+
throw new StatusError(400, "Invalid Resend webhook signature");
30+
}
31+
}
32+
33+
type ResendDomainWebhookPayload = {
34+
type?: string,
35+
data?: {
36+
id?: string,
37+
status?: string,
38+
error?: string,
39+
},
40+
};
41+
42+
export const POST = createSmartRouteHandler({
43+
metadata: {
44+
hidden: true,
45+
},
46+
request: yupObject({
47+
headers: yupObject({
48+
"svix-id": yupTuple([yupString().defined()]).defined(),
49+
"svix-timestamp": yupTuple([yupString().defined()]).defined(),
50+
"svix-signature": yupTuple([yupString().defined()]).defined(),
51+
}).defined(),
52+
body: yupMixed().optional(),
53+
method: yupString().oneOf(["POST"]).defined(),
54+
}),
55+
response: yupObject({
56+
statusCode: yupNumber().oneOf([200]).defined(),
57+
bodyType: yupString().oneOf(["json"]).defined(),
58+
body: yupObject({
59+
received: yupBoolean().defined(),
60+
}).defined(),
61+
}),
62+
handler: async (req, fullReq) => {
63+
ensureResendWebhookSignature(req.headers, fullReq.bodyBuffer);
64+
65+
const payloadResult = Result.fromThrowing(() => JSON.parse(decodeBody(fullReq.bodyBuffer)) as ResendDomainWebhookPayload);
66+
if (payloadResult.status === "error") {
67+
throw new StatusError(400, "Invalid JSON payload in Resend webhook");
68+
}
69+
if (payloadResult.data.type !== "domain.updated") {
70+
return {
71+
statusCode: 200,
72+
bodyType: "json",
73+
body: { received: true },
74+
};
75+
}
76+
const payload = payloadResult.data;
77+
78+
const domainId = payload.data?.id;
79+
const providerStatusRaw = payload.data?.status;
80+
if (domainId == null || providerStatusRaw == null) {
81+
throw new StackAssertionError("Resend webhook payload missing required domain fields", {
82+
payload,
83+
});
84+
}
85+
86+
await processResendDomainWebhookEvent({
87+
domainId,
88+
providerStatusRaw,
89+
errorMessage: payload.data?.error,
90+
});
91+
92+
return {
93+
statusCode: 200,
94+
bodyType: "json",
95+
body: {
96+
received: true,
97+
},
98+
};
99+
},
100+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { applyManagedEmailProvider } from "@/lib/managed-email-onboarding";
2+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
3+
import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
4+
5+
export const POST = createSmartRouteHandler({
6+
metadata: {
7+
hidden: true,
8+
},
9+
request: yupObject({
10+
auth: yupObject({
11+
type: adminAuthTypeSchema.defined(),
12+
tenancy: adaptSchema.defined(),
13+
}).defined(),
14+
body: yupObject({
15+
domain_id: yupString().defined(),
16+
}).defined(),
17+
method: yupString().oneOf(["POST"]).defined(),
18+
}),
19+
response: yupObject({
20+
statusCode: yupNumber().oneOf([200]).defined(),
21+
bodyType: yupString().oneOf(["json"]).defined(),
22+
body: yupObject({
23+
status: yupString().oneOf(["applied"]).defined(),
24+
}).defined(),
25+
}),
26+
handler: async ({ auth, body }) => {
27+
const result = await applyManagedEmailProvider({
28+
tenancy: auth.tenancy,
29+
domainId: body.domain_id,
30+
});
31+
32+
return {
33+
statusCode: 200,
34+
bodyType: "json",
35+
body: {
36+
status: result.status,
37+
},
38+
};
39+
},
40+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { checkManagedEmailProviderStatus } from "@/lib/managed-email-onboarding";
2+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
3+
import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
4+
5+
export const POST = createSmartRouteHandler({
6+
metadata: {
7+
hidden: true,
8+
},
9+
request: yupObject({
10+
auth: yupObject({
11+
type: adminAuthTypeSchema.defined(),
12+
tenancy: adaptSchema.defined(),
13+
}).defined(),
14+
body: yupObject({
15+
domain_id: yupString().defined(),
16+
subdomain: yupString().defined(),
17+
sender_local_part: yupString().defined(),
18+
}).defined(),
19+
method: yupString().oneOf(["POST"]).defined(),
20+
}),
21+
response: yupObject({
22+
statusCode: yupNumber().oneOf([200]).defined(),
23+
bodyType: yupString().oneOf(["json"]).defined(),
24+
body: yupObject({
25+
status: yupString().oneOf(["pending_dns", "pending_verification", "verified", "applied", "failed"]).defined(),
26+
}).defined(),
27+
}),
28+
handler: async ({ auth, body }) => {
29+
const checkResult = await checkManagedEmailProviderStatus({
30+
tenancy: auth.tenancy,
31+
domainId: body.domain_id,
32+
subdomain: body.subdomain,
33+
senderLocalPart: body.sender_local_part,
34+
});
35+
36+
return {
37+
statusCode: 200,
38+
bodyType: "json",
39+
body: {
40+
status: checkResult.status,
41+
},
42+
};
43+
},
44+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { listManagedEmailProviderDomains } from "@/lib/managed-email-onboarding";
2+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
3+
import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
4+
5+
export const GET = createSmartRouteHandler({
6+
metadata: {
7+
hidden: true,
8+
},
9+
request: yupObject({
10+
auth: yupObject({
11+
type: adminAuthTypeSchema.defined(),
12+
tenancy: adaptSchema.defined(),
13+
}).defined(),
14+
method: yupString().oneOf(["GET"]).defined(),
15+
}),
16+
response: yupObject({
17+
statusCode: yupNumber().oneOf([200]).defined(),
18+
bodyType: yupString().oneOf(["json"]).defined(),
19+
body: yupObject({
20+
items: yupArray(yupObject({
21+
domain_id: yupString().defined(),
22+
subdomain: yupString().defined(),
23+
sender_local_part: yupString().defined(),
24+
status: yupString().oneOf(["pending_dns", "pending_verification", "verified", "applied", "failed"]).defined(),
25+
name_server_records: yupArray(yupString().defined()).defined(),
26+
}).defined()).defined(),
27+
}).defined(),
28+
}),
29+
handler: async ({ auth }) => {
30+
const items = await listManagedEmailProviderDomains({
31+
tenancy: auth.tenancy,
32+
});
33+
34+
return {
35+
statusCode: 200,
36+
bodyType: "json",
37+
body: {
38+
items: items.map((item) => ({
39+
domain_id: item.domainId,
40+
subdomain: item.subdomain,
41+
sender_local_part: item.senderLocalPart,
42+
status: item.status,
43+
name_server_records: item.nameServerRecords,
44+
})),
45+
},
46+
};
47+
},
48+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { setupManagedEmailProvider } from "@/lib/managed-email-onboarding";
2+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
3+
import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
4+
5+
export const POST = createSmartRouteHandler({
6+
metadata: {
7+
hidden: true,
8+
},
9+
request: yupObject({
10+
auth: yupObject({
11+
type: adminAuthTypeSchema.defined(),
12+
tenancy: adaptSchema.defined(),
13+
}).defined(),
14+
body: yupObject({
15+
subdomain: yupString().defined(),
16+
sender_local_part: yupString().defined(),
17+
}).defined(),
18+
method: yupString().oneOf(["POST"]).defined(),
19+
}),
20+
response: yupObject({
21+
statusCode: yupNumber().oneOf([200]).defined(),
22+
bodyType: yupString().oneOf(["json"]).defined(),
23+
body: yupObject({
24+
domain_id: yupString().defined(),
25+
subdomain: yupString().defined(),
26+
sender_local_part: yupString().defined(),
27+
name_server_records: yupArray(yupString().defined()).defined(),
28+
status: yupString().oneOf(["pending_dns", "pending_verification", "verified", "applied", "failed"]).defined(),
29+
}).defined(),
30+
}),
31+
handler: async ({ auth, body }) => {
32+
const setupResult = await setupManagedEmailProvider({
33+
subdomain: body.subdomain,
34+
senderLocalPart: body.sender_local_part,
35+
tenancy: auth.tenancy,
36+
});
37+
38+
return {
39+
statusCode: 200,
40+
bodyType: "json",
41+
body: {
42+
domain_id: setupResult.domainId,
43+
subdomain: setupResult.subdomain,
44+
sender_local_part: setupResult.senderLocalPart,
45+
name_server_records: setupResult.nameServerRecords,
46+
status: setupResult.status,
47+
},
48+
};
49+
},
50+
});

apps/backend/src/lib/config.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,6 +1090,16 @@ export const renderedOrganizationConfigToProjectCrud = (renderedConfig: Complete
10901090

10911091
email_config: renderedConfig.emails.server.isShared ? {
10921092
type: 'shared',
1093+
} : renderedConfig.emails.server.provider === "managed" ? {
1094+
type: 'standard',
1095+
host: "smtp.resend.com",
1096+
port: 465,
1097+
username: "resend",
1098+
password: renderedConfig.emails.server.password,
1099+
sender_name: renderedConfig.emails.server.senderName,
1100+
sender_email: renderedConfig.emails.server.managedSubdomain && renderedConfig.emails.server.managedSenderLocalPart
1101+
? `${renderedConfig.emails.server.managedSenderLocalPart}@${renderedConfig.emails.server.managedSubdomain}`
1102+
: renderedConfig.emails.server.senderEmail,
10931103
} : {
10941104
type: 'standard',
10951105
host: renderedConfig.emails.server.host,

0 commit comments

Comments
 (0)