Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/backend/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,10 @@ STACK_CLICKHOUSE_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}36
STACK_CLICKHOUSE_ADMIN_USER=stackframe
STACK_CLICKHOUSE_ADMIN_PASSWORD=PASSWORD-PLACEHOLDER--9gKyMxJeMx
STACK_CLICKHOUSE_EXTERNAL_PASSWORD=PASSWORD-PLACEHOLDER--EZeHscBMzE

# Managed emails
STACK_RESEND_API_KEY=mock_resend_api_key
STACK_RESEND_WEBHOOK_SECRET=mock_resend_webhook_secret
STACK_DNSIMPLE_API_TOKEN=mock_dnsimple_api_token
STACK_DNSIMPLE_ACCOUNT_ID=mock_dnsimple_account_id
STACK_DNSIMPLE_API_BASE_URL=https://api.dnsimple.com/v2
Comment thread
BilalG1 marked this conversation as resolved.
Comment thread
BilalG1 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
CREATE TYPE "ManagedEmailDomainStatus" AS ENUM ('PENDING_DNS', 'PENDING_VERIFICATION', 'VERIFIED', 'APPLIED', 'FAILED');

CREATE TABLE "ManagedEmailDomain" (
"id" UUID NOT NULL,
"tenancyId" UUID NOT NULL,
"projectId" TEXT NOT NULL,
"branchId" TEXT NOT NULL,
"subdomain" TEXT NOT NULL,
"senderLocalPart" TEXT NOT NULL,
Comment thread
BilalG1 marked this conversation as resolved.
"resendDomainId" TEXT NOT NULL,
"nameServerRecords" JSONB NOT NULL,
"status" "ManagedEmailDomainStatus" NOT NULL DEFAULT 'PENDING_VERIFICATION',
"providerStatusRaw" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"lastError" TEXT,
"verifiedAt" TIMESTAMP(3),
"appliedAt" TIMESTAMP(3),
"lastWebhookAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "ManagedEmailDomain_pkey" PRIMARY KEY ("id"),
CONSTRAINT "ManagedEmailDomain_tenancyId_fkey" FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE
);

CREATE UNIQUE INDEX "ManagedEmailDomain_resendDomainId_key" ON "ManagedEmailDomain"("resendDomainId");
CREATE UNIQUE INDEX "ManagedEmailDomain_tenancyId_subdomain_key" ON "ManagedEmailDomain"("tenancyId", "subdomain");
CREATE INDEX "ManagedEmailDomain_tenancy_status_active_idx" ON "ManagedEmailDomain"("tenancyId", "status", "isActive");
38 changes: 38 additions & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,49 @@ model Tenancy {
emailOutboxes EmailOutbox[]
sessionReplays SessionReplay[]
sessionReplayChunks SessionReplayChunk[]
managedEmailDomains ManagedEmailDomain[]

@@unique([projectId, branchId, organizationId])
@@unique([projectId, branchId, hasNoOrganization])
}

enum ManagedEmailDomainStatus {
PENDING_DNS
PENDING_VERIFICATION
VERIFIED
APPLIED
FAILED
}

model ManagedEmailDomain {
id String @id @default(uuid()) @db.Uuid

tenancyId String @db.Uuid
tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade)

projectId String
branchId String

subdomain String
senderLocalPart String
resendDomainId String @unique
nameServerRecords Json

status ManagedEmailDomainStatus @default(PENDING_VERIFICATION)
providerStatusRaw String?
isActive Boolean @default(true)
lastError String?
verifiedAt DateTime?
appliedAt DateTime?
lastWebhookAt DateTime?

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@unique([tenancyId, subdomain])
@@index([tenancyId, status, isActive], map: "ManagedEmailDomain_tenancy_status_active_idx")
}

model BranchConfigOverride {
projectId String
branchId String
Expand Down
100 changes: 100 additions & 0 deletions apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { processResendDomainWebhookEvent } from "@/lib/managed-email-onboarding";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { yupBoolean, yupMixed, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { Webhook } from "svix";

function decodeBody(bodyBuffer: ArrayBuffer) {
return new TextDecoder().decode(bodyBuffer);
}

function ensureResendWebhookSignature(headers: Record<string, string[] | undefined>, bodyBuffer: ArrayBuffer) {
const webhookSecret = getEnvVariable("STACK_RESEND_WEBHOOK_SECRET");
const svixId = headers["svix-id"]?.[0] ?? null;
const svixTimestamp = headers["svix-timestamp"]?.[0] ?? null;
const svixSignature = headers["svix-signature"]?.[0] ?? null;
if (svixId == null || svixTimestamp == null || svixSignature == null) {
throw new StatusError(400, "Missing Svix signature headers for Resend webhook");
}

const verifier = new Webhook(webhookSecret);
const result = Result.fromThrowing(() => verifier.verify(decodeBody(bodyBuffer), {
"svix-id": svixId,
"svix-timestamp": svixTimestamp,
"svix-signature": svixSignature,
}));
if (result.status === "error") {
throw new StatusError(400, "Invalid Resend webhook signature");
}
}

type ResendDomainWebhookPayload = {
type?: string,
data?: {
id?: string,
status?: string,
error?: string,
},
};

export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
headers: yupObject({
"svix-id": yupTuple([yupString().defined()]).defined(),
"svix-timestamp": yupTuple([yupString().defined()]).defined(),
"svix-signature": yupTuple([yupString().defined()]).defined(),
}).defined(),
body: yupMixed().optional(),
method: yupString().oneOf(["POST"]).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
received: yupBoolean().defined(),
}).defined(),
}),
handler: async (req, fullReq) => {
ensureResendWebhookSignature(req.headers, fullReq.bodyBuffer);

const payloadResult = Result.fromThrowing(() => JSON.parse(decodeBody(fullReq.bodyBuffer)) as ResendDomainWebhookPayload);
if (payloadResult.status === "error") {
throw new StatusError(400, "Invalid JSON payload in Resend webhook");
}
if (payloadResult.data.type !== "domain.updated") {
return {
statusCode: 200,
bodyType: "json",
body: { received: true },
};
}
const payload = payloadResult.data;

const domainId = payload.data?.id;
const providerStatusRaw = payload.data?.status;
if (domainId == null || providerStatusRaw == null) {
throw new StackAssertionError("Resend webhook payload missing required domain fields", {
payload,
});
}

await processResendDomainWebhookEvent({
domainId,
providerStatusRaw,
errorMessage: payload.data?.error,
});

return {
statusCode: 200,
bodyType: "json",
body: {
received: true,
},
};
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { applyManagedEmailProvider } from "@/lib/managed-email-onboarding";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";

export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
auth: yupObject({
type: adminAuthTypeSchema.defined(),
tenancy: adaptSchema.defined(),
}).defined(),
body: yupObject({
domain_id: yupString().defined(),
}).defined(),
method: yupString().oneOf(["POST"]).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
status: yupString().oneOf(["applied"]).defined(),
}).defined(),
}),
handler: async ({ auth, body }) => {
const result = await applyManagedEmailProvider({
tenancy: auth.tenancy,
domainId: body.domain_id,
});

return {
statusCode: 200,
bodyType: "json",
body: {
status: result.status,
},
};
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { checkManagedEmailProviderStatus } from "@/lib/managed-email-onboarding";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";

export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
auth: yupObject({
type: adminAuthTypeSchema.defined(),
tenancy: adaptSchema.defined(),
}).defined(),
body: yupObject({
domain_id: yupString().defined(),
subdomain: yupString().defined(),
sender_local_part: yupString().defined(),
}).defined(),
method: yupString().oneOf(["POST"]).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
status: yupString().oneOf(["pending_dns", "pending_verification", "verified", "applied", "failed"]).defined(),
}).defined(),
}),
handler: async ({ auth, body }) => {
const checkResult = await checkManagedEmailProviderStatus({
tenancy: auth.tenancy,
domainId: body.domain_id,
subdomain: body.subdomain,
senderLocalPart: body.sender_local_part,
});

return {
statusCode: 200,
bodyType: "json",
body: {
status: checkResult.status,
},
};
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { listManagedEmailProviderDomains } from "@/lib/managed-email-onboarding";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";

export const GET = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
auth: yupObject({
type: adminAuthTypeSchema.defined(),
tenancy: adaptSchema.defined(),
}).defined(),
method: yupString().oneOf(["GET"]).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
items: yupArray(yupObject({
domain_id: yupString().defined(),
subdomain: yupString().defined(),
sender_local_part: yupString().defined(),
status: yupString().oneOf(["pending_dns", "pending_verification", "verified", "applied", "failed"]).defined(),
name_server_records: yupArray(yupString().defined()).defined(),
}).defined()).defined(),
}).defined(),
}),
handler: async ({ auth }) => {
const items = await listManagedEmailProviderDomains({
tenancy: auth.tenancy,
});

return {
statusCode: 200,
bodyType: "json",
body: {
items: items.map((item) => ({
domain_id: item.domainId,
subdomain: item.subdomain,
sender_local_part: item.senderLocalPart,
status: item.status,
name_server_records: item.nameServerRecords,
})),
},
};
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { setupManagedEmailProvider } from "@/lib/managed-email-onboarding";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";

export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
auth: yupObject({
type: adminAuthTypeSchema.defined(),
tenancy: adaptSchema.defined(),
}).defined(),
body: yupObject({
subdomain: yupString().defined(),
sender_local_part: yupString().defined(),
}).defined(),
method: yupString().oneOf(["POST"]).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
domain_id: yupString().defined(),
subdomain: yupString().defined(),
sender_local_part: yupString().defined(),
name_server_records: yupArray(yupString().defined()).defined(),
status: yupString().oneOf(["pending_dns", "pending_verification", "verified", "applied", "failed"]).defined(),
}).defined(),
}),
handler: async ({ auth, body }) => {
const setupResult = await setupManagedEmailProvider({
subdomain: body.subdomain,
senderLocalPart: body.sender_local_part,
tenancy: auth.tenancy,
});

return {
statusCode: 200,
bodyType: "json",
body: {
domain_id: setupResult.domainId,
subdomain: setupResult.subdomain,
sender_local_part: setupResult.senderLocalPart,
name_server_records: setupResult.nameServerRecords,
status: setupResult.status,
},
};
},
});
10 changes: 10 additions & 0 deletions apps/backend/src/lib/config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading