feat: add ses tenant support for teams (#193)
This commit is contained in:
@@ -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",
|
||||||
|
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Team" ADD COLUMN "sesTenantId" TEXT;
|
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Domain" ADD COLUMN "sesTenantId" TEXT;
|
@@ -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
|
||||||
|
@@ -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 }) => {
|
||||||
|
@@ -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 {
|
||||||
|
@@ -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);
|
||||||
});
|
});
|
||||||
|
@@ -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");
|
||||||
|
@@ -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(
|
||||||
|
@@ -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
922
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user