feat: add ses tenant support for teams (#193)

This commit is contained in:
KM Koushik
2025-08-09 20:37:29 +10:00
committed by GitHub
parent e3b8a451da
commit da13107f88
11 changed files with 947 additions and 93 deletions

View File

@@ -19,8 +19,9 @@
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.9.0", "@auth/prisma-adapter": "^2.9.0",
"@aws-sdk/client-s3": "^3.797.0", "@aws-sdk/client-s3": "^3.797.0",
"@aws-sdk/client-sesv2": "^3.797.0", "@aws-sdk/client-sesv2": "^3.858.0",
"@aws-sdk/client-sns": "^3.797.0", "@aws-sdk/client-sns": "^3.797.0",
"@aws-sdk/client-sts": "^3.864.0",
"@aws-sdk/s3-request-presigner": "^3.797.0", "@aws-sdk/s3-request-presigner": "^3.797.0",
"@hono/swagger-ui": "^0.5.1", "@hono/swagger-ui": "^0.5.1",
"@hono/zod-openapi": "^0.10.0", "@hono/zod-openapi": "^0.10.0",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Team" ADD COLUMN "sesTenantId" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Domain" ADD COLUMN "sesTenantId" TEXT;

View File

@@ -106,6 +106,7 @@ model Team {
isActive Boolean @default(true) isActive Boolean @default(true)
apiRateLimit Int @default(2) apiRateLimit Int @default(2)
billingEmail String? billingEmail String?
sesTenantId String?
teamUsers TeamUser[] teamUsers TeamUser[]
domains Domain[] domains Domain[]
apiKeys ApiKey[] apiKeys ApiKey[]
@@ -184,6 +185,7 @@ model Domain {
dmarcAdded Boolean @default(false) dmarcAdded Boolean @default(false)
errorMessage String? errorMessage String?
subdomain String? subdomain String?
sesTenantId String?
isVerifying Boolean @default(false) isVerifying Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@@ -25,7 +25,12 @@ export const domainRouter = createTRPCRouter({
createDomain: teamProcedure createDomain: teamProcedure
.input(z.object({ name: z.string(), region: z.string() })) .input(z.object({ name: z.string(), region: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
return createDomain(ctx.team.id, input.name, input.region); return createDomain(
ctx.team.id,
input.name,
input.region,
ctx.team.sesTenantId ?? undefined
);
}), }),
startVerification: domainProcedure.mutation(async ({ ctx, input }) => { startVerification: domainProcedure.mutation(async ({ ctx, input }) => {

View File

@@ -9,16 +9,38 @@ import {
CreateConfigurationSetCommand, CreateConfigurationSetCommand,
EventType, EventType,
GetAccountCommand, GetAccountCommand,
CreateTenantResourceAssociationCommand,
DeleteTenantResourceAssociationCommand,
} from "@aws-sdk/client-sesv2"; } from "@aws-sdk/client-sesv2";
import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts";
import { generateKeyPairSync } from "crypto"; import { generateKeyPairSync } from "crypto";
import mime from "mime-types";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { Readable } from "stream";
import { env } from "~/env"; import { env } from "~/env";
import { EmailContent } from "~/types"; import { EmailContent } from "~/types";
import { nanoid } from "../nanoid"; import { nanoid } from "../nanoid";
import { logger } from "../logger/log"; import { logger } from "../logger/log";
let accountId: string | undefined = undefined;
async function getAccountId(region: string) {
if (accountId) {
return accountId;
}
const stsClient = new STSClient({
region: region,
});
const command = new GetCallerIdentityCommand({});
const response = await stsClient.send(command);
accountId = response.Account;
return accountId;
}
async function getIdentityArn(domain: string, region: string) {
const accountId = await getAccountId(region);
return `arn:aws:ses:${region}:${accountId}:identity/${domain}`;
}
function getSesClient(region: string) { function getSesClient(region: string) {
return new SESv2Client({ return new SESv2Client({
region: region, region: region,
@@ -56,7 +78,11 @@ function generateKeyPair() {
return { privateKey: base64PrivateKey, publicKey: base64PublicKey }; return { privateKey: base64PrivateKey, publicKey: base64PublicKey };
} }
export async function addDomain(domain: string, region: string) { export async function addDomain(
domain: string,
region: string,
sesTenantId?: string
) {
const sesClient = getSesClient(region); const sesClient = getSesClient(region);
const { privateKey, publicKey } = generateKeyPair(); const { privateKey, publicKey } = generateKeyPair();
@@ -76,6 +102,26 @@ export async function addDomain(domain: string, region: string) {
const emailIdentityResponse = await sesClient.send(emailIdentityCommand); const emailIdentityResponse = await sesClient.send(emailIdentityCommand);
if (sesTenantId) {
const tenantResourceAssociationCommand =
new CreateTenantResourceAssociationCommand({
TenantName: sesTenantId,
ResourceArn: await getIdentityArn(domain, region),
});
const tenantResourceAssociationResponse = await sesClient.send(
tenantResourceAssociationCommand
);
if (tenantResourceAssociationResponse.$metadata.httpStatusCode !== 200) {
logger.error(
{ tenantResourceAssociationResponse },
"Failed to associate domain with tenant"
);
throw new Error("Failed to associate domain with tenant");
}
}
if ( if (
response.$metadata.httpStatusCode !== 200 || response.$metadata.httpStatusCode !== 200 ||
emailIdentityResponse.$metadata.httpStatusCode !== 200 emailIdentityResponse.$metadata.httpStatusCode !== 200
@@ -90,8 +136,33 @@ export async function addDomain(domain: string, region: string) {
return publicKey; return publicKey;
} }
export async function deleteDomain(domain: string, region: string) { export async function deleteDomain(
domain: string,
region: string,
sesTenantId?: string
) {
const sesClient = getSesClient(region); const sesClient = getSesClient(region);
if (sesTenantId) {
const tenantResourceAssociationCommand =
new DeleteTenantResourceAssociationCommand({
TenantName: sesTenantId,
ResourceArn: await getIdentityArn(domain, region),
});
const tenantResourceAssociationResponse = await sesClient.send(
tenantResourceAssociationCommand
);
if (tenantResourceAssociationResponse.$metadata.httpStatusCode !== 200) {
logger.error(
{ tenantResourceAssociationResponse },
"Failed to delete tenant resource association"
);
throw new Error("Failed to delete tenant resource association");
}
}
const command = new DeleteEmailIdentityCommand({ const command = new DeleteEmailIdentityCommand({
EmailIdentity: domain, EmailIdentity: domain,
}); });
@@ -124,6 +195,7 @@ export async function sendRawEmail({
isBulk, isBulk,
inReplyToMessageId, inReplyToMessageId,
emailId, emailId,
sesTenantId,
}: Partial<EmailContent> & { }: Partial<EmailContent> & {
region: string; region: string;
configurationSetName: string; configurationSetName: string;
@@ -187,6 +259,7 @@ export async function sendRawEmail({
}, },
}, },
ConfigurationSetName: configurationSetName, ConfigurationSetName: configurationSetName,
TenantName: sesTenantId ? sesTenantId : undefined,
}); });
try { try {

View File

@@ -36,7 +36,12 @@ function createDomain(app: PublicAPIApp) {
app.openapi(route, async (c) => { app.openapi(route, async (c) => {
const team = c.var.team; const team = c.var.team;
const body = c.req.valid("json"); const body = c.req.valid("json");
const response = await createDomainService(team.id, body.name, body.region); const response = await createDomainService(
team.id,
body.name,
body.region,
team.sesTenantId ?? undefined
);
return c.json(response); return c.json(response);
}); });

View File

@@ -57,7 +57,8 @@ export async function validateDomainFromEmail(email: string, teamId: number) {
export async function createDomain( export async function createDomain(
teamId: number, teamId: number,
name: string, name: string,
region: string region: string,
sesTenantId?: string
) { ) {
const domainStr = tldts.getDomain(name); const domainStr = tldts.getDomain(name);
@@ -74,7 +75,7 @@ export async function createDomain(
} }
const subdomain = tldts.getSubdomain(name); const subdomain = tldts.getSubdomain(name);
const publicKey = await ses.addDomain(name, region); const publicKey = await ses.addDomain(name, region, sesTenantId);
const domain = await db.domain.create({ const domain = await db.domain.create({
data: { data: {
@@ -83,6 +84,7 @@ export async function createDomain(
teamId, teamId,
subdomain, subdomain,
region, region,
sesTenantId,
}, },
}); });
@@ -165,7 +167,11 @@ export async function deleteDomain(id: number) {
throw new Error("Domain not found"); throw new Error("Domain not found");
} }
const deleted = await ses.deleteDomain(domain.name, domain.region); const deleted = await ses.deleteDomain(
domain.name,
domain.region,
domain.sesTenantId ?? undefined
);
if (!deleted) { if (!deleted) {
throw new Error("Error in deleting domain"); throw new Error("Error in deleting domain");

View File

@@ -382,6 +382,7 @@ async function executeEmail(job: QueueEmailJob) {
isBulk, isBulk,
inReplyToMessageId, inReplyToMessageId,
emailId: email.id, emailId: email.id,
sesTenantId: domain?.sesTenantId,
}); });
logger.info( logger.info(

View File

@@ -13,6 +13,7 @@ export type EmailContent = {
unsubUrl?: string; unsubUrl?: string;
scheduledAt?: string; scheduledAt?: string;
inReplyToId?: string | null; inReplyToId?: string | null;
sesTenantId?: string | null;
}; };
export type EmailAttachment = { export type EmailAttachment = {

922
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff