Improve Self host setup (#30)

* Add self host setup

* Improve blunders

* Move to bull mq

* More changes

* Add example code for sending test emails
This commit is contained in:
KM Koushik
2024-06-24 08:21:37 +10:00
committed by GitHub
parent 8a2769621c
commit f77a8829be
67 changed files with 1771 additions and 688 deletions

View File

@@ -3,6 +3,7 @@ import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
import { apiRouter } from "./routers/api";
import { emailRouter } from "./routers/email";
import { teamRouter } from "./routers/team";
import { adminRouter } from "./routers/admin";
/**
* This is the primary router for your server.
@@ -14,6 +15,7 @@ export const appRouter = createTRPCRouter({
apiKey: apiRouter,
email: emailRouter,
team: teamRouter,
admin: adminRouter,
});
// export type definition of API

View File

@@ -3,12 +3,24 @@ import { env } from "~/env";
import { createTRPCRouter, adminProcedure } from "~/server/api/trpc";
import { SesSettingsService } from "~/server/service/ses-settings-service";
import { getAccount } from "~/server/aws/ses";
export const adminRouter = createTRPCRouter({
getSesSettings: adminProcedure.query(async () => {
return SesSettingsService.getAllSettings();
}),
getQuotaForRegion: adminProcedure
.input(
z.object({
region: z.string(),
})
)
.query(async ({ input }) => {
const acc = await getAccount(input.region);
return acc.SendQuota?.MaxSendRate;
}),
addSesSettings: adminProcedure
.input(
z.object({

View File

@@ -1,6 +1,10 @@
import { z } from "zod";
import { createTRPCRouter, teamProcedure } from "~/server/api/trpc";
import {
createTRPCRouter,
teamProcedure,
protectedProcedure,
} from "~/server/api/trpc";
import { db } from "~/server/db";
import {
createDomain,
@@ -9,12 +13,18 @@ import {
updateDomain,
} from "~/server/service/domain-service";
import { sendEmail } from "~/server/service/email-service";
import { SesSettingsService } from "~/server/service/ses-settings-service";
export const domainRouter = createTRPCRouter({
getAvailableRegions: protectedProcedure.query(async () => {
const settings = await SesSettingsService.getAllSettings();
return settings.map((setting) => setting.region);
}),
createDomain: teamProcedure
.input(z.object({ name: z.string() }))
.input(z.object({ name: z.string(), region: z.string() }))
.mutation(async ({ ctx, input }) => {
return createDomain(ctx.team.id, input.name);
return createDomain(ctx.team.id, input.name, input.region);
}),
startVerification: teamProcedure
@@ -93,9 +103,9 @@ export const domainRouter = createTRPCRouter({
teamId: team.id,
to: user.email,
from: `hello@${domain.name}`,
subject: "Test mail",
text: "Hello this is a test mail",
html: "<p>Hello this is a test mail</p>",
subject: "Unsend test email",
text: "hello,\n\nUnsend is the best open source sending platform\n\ncheck out https://unsend.dev",
html: "<p>hello,</p><p>Unsend is the best open source sending platform<p><p>check out <a href='https://unsend.dev'>unsend.dev</a>",
});
}
),

View File

@@ -25,6 +25,7 @@ declare module "next-auth" {
user: {
id: number;
isBetaUser: boolean;
isAdmin: boolean;
// ...other properties
// role: UserRole;
} & DefaultSession["user"];
@@ -34,6 +35,7 @@ declare module "next-auth" {
interface User {
id: number;
isBetaUser: boolean;
isAdmin: boolean;
}
}
@@ -86,6 +88,7 @@ export const authOptions: NextAuthOptions = {
...session.user,
id: user.id,
isBetaUser: user.isBetaUser,
isAdmin: user.email === env.ADMIN_EMAIL,
},
}),
},

View File

@@ -8,14 +8,14 @@ import {
CreateConfigurationSetEventDestinationCommand,
CreateConfigurationSetCommand,
EventType,
GetAccountCommand,
} from "@aws-sdk/client-sesv2";
import { generateKeyPairSync } from "crypto";
import mime from "mime-types";
import { env } from "~/env";
import { EmailContent } from "~/types";
import { APP_SETTINGS } from "~/utils/constants";
function getSesClient(region = "us-east-1") {
function getSesClient(region: string) {
return new SESv2Client({
region: region,
credentials: {
@@ -51,7 +51,7 @@ function generateKeyPair() {
return { privateKey: base64PrivateKey, publicKey: base64PublicKey };
}
export async function addDomain(domain: string, region = "us-east-1") {
export async function addDomain(domain: string, region: string) {
const sesClient = getSesClient(region);
const { privateKey, publicKey } = generateKeyPair();
@@ -61,7 +61,6 @@ export async function addDomain(domain: string, region = "us-east-1") {
DomainSigningSelector: "unsend",
DomainSigningPrivateKey: privateKey,
},
ConfigurationSetName: APP_SETTINGS.SES_CONFIGURATION_GENERAL,
});
const response = await sesClient.send(command);
@@ -84,7 +83,7 @@ export async function addDomain(domain: string, region = "us-east-1") {
return publicKey;
}
export async function deleteDomain(domain: string, region = "us-east-1") {
export async function deleteDomain(domain: string, region: string) {
const sesClient = getSesClient(region);
const command = new DeleteEmailIdentityCommand({
EmailIdentity: domain,
@@ -93,7 +92,7 @@ export async function deleteDomain(domain: string, region = "us-east-1") {
return response.$metadata.httpStatusCode === 200;
}
export async function getDomainIdentity(domain: string, region = "us-east-1") {
export async function getDomainIdentity(domain: string, region: string) {
const sesClient = getSesClient(region);
const command = new GetEmailIdentityCommand({
EmailIdentity: domain,
@@ -106,21 +105,29 @@ export async function sendEmailThroughSes({
to,
from,
subject,
cc,
bcc,
text,
html,
replyTo,
region = "us-east-1",
region,
configurationSetName,
}: EmailContent & {
region?: string;
}: Partial<EmailContent> & {
region: string;
configurationSetName: string;
cc?: string[];
bcc?: string[];
replyTo?: string[];
to?: string[];
}) {
const sesClient = getSesClient(region);
const command = new SendEmailCommand({
FromEmailAddress: from,
ReplyToAddresses: replyTo ? [replyTo] : undefined,
ReplyToAddresses: replyTo ? replyTo : undefined,
Destination: {
ToAddresses: [to],
ToAddresses: to,
CcAddresses: cc,
BccAddresses: bcc,
},
Content: {
// EmailContent
@@ -153,7 +160,7 @@ export async function sendEmailThroughSes({
return response.MessageId;
} catch (error) {
console.error("Failed to send email", error);
throw new Error("Failed to send email");
throw error;
}
}
@@ -163,21 +170,29 @@ export async function sendEmailWithAttachments({
from,
subject,
replyTo,
cc,
bcc,
// eslint-disable-next-line no-unused-vars
text,
html,
attachments,
region = "us-east-1",
region,
configurationSetName,
}: EmailContent & {
region?: string;
}: Partial<EmailContent> & {
region: string;
configurationSetName: string;
attachments: { filename: string; content: string }[];
cc?: string[];
bcc?: string[];
replyTo?: string[];
to?: string[];
}) {
const sesClient = getSesClient(region);
const boundary = "NextPart";
let rawEmail = `From: ${from}\n`;
rawEmail += `To: ${to}\n`;
rawEmail += `To: ${Array.isArray(to) ? to.join(", ") : to}\n`;
rawEmail += `Cc: ${cc ? cc.join(", ") : ""}\n`;
rawEmail += `Bcc: ${bcc ? bcc.join(", ") : ""}\n`;
rawEmail += `Reply-To: ${replyTo}\n`;
rawEmail += `Subject: ${subject}\n`;
rawEmail += `MIME-Version: 1.0\n`;
@@ -217,11 +232,18 @@ export async function sendEmailWithAttachments({
}
}
export async function getAccount(region: string) {
const client = getSesClient(region);
const input = new GetAccountCommand({});
const response = await client.send(input);
return response;
}
export async function addWebhookConfiguration(
configName: string,
topicArn: string,
eventTypes: EventType[],
region = "us-east-1"
region: string
) {
const sesClient = getSesClient(region);

View File

@@ -1,99 +0,0 @@
import { APP_SETTINGS } from "~/utils/constants";
import { createTopic, subscribeEndpoint } from "./sns";
import { env } from "~/env";
import { AppSettingsService } from "~/server/service/app-settings-service";
import { addWebhookConfiguration } from "./ses";
import { EventType } from "@aws-sdk/client-sesv2";
const GENERAL_EVENTS: EventType[] = [
"BOUNCE",
"COMPLAINT",
"DELIVERY",
"DELIVERY_DELAY",
"REJECT",
"RENDERING_FAILURE",
"SEND",
"SUBSCRIPTION",
];
export async function setupAws() {
AppSettingsService.initializeCache();
let snsTopicArn = await AppSettingsService.getSetting(
APP_SETTINGS.SNS_TOPIC_ARN
);
console.log("Setting up AWS");
if (!snsTopicArn) {
console.log("SNS topic not present, creating...");
snsTopicArn = await createUnsendSNSTopic();
}
await setupSESConfiguration();
}
async function createUnsendSNSTopic() {
const topicArn = await createTopic(env.SNS_TOPIC);
if (!topicArn) {
console.error("Failed to create SNS topic");
return;
}
await subscribeEndpoint(
topicArn,
`${env.APP_URL ?? env.NEXTAUTH_URL}/api/ses_callback`
);
return await AppSettingsService.setSetting(
APP_SETTINGS.SNS_TOPIC_ARN,
topicArn
);
}
async function setupSESConfiguration() {
const topicArn = (
await AppSettingsService.getSetting(APP_SETTINGS.SNS_TOPIC_ARN)
)?.toString();
if (!topicArn) {
return;
}
console.log("Setting up SES webhook configuration");
await setWebhookConfiguration(
APP_SETTINGS.SES_CONFIGURATION_GENERAL,
topicArn,
GENERAL_EVENTS
);
await setWebhookConfiguration(
APP_SETTINGS.SES_CONFIGURATION_CLICK_TRACKING,
topicArn,
[...GENERAL_EVENTS, "CLICK"]
);
await setWebhookConfiguration(
APP_SETTINGS.SES_CONFIGURATION_OPEN_TRACKING,
topicArn,
[...GENERAL_EVENTS, "OPEN"]
);
await setWebhookConfiguration(APP_SETTINGS.SES_CONFIGURATION_FULL, topicArn, [
...GENERAL_EVENTS,
"CLICK",
"OPEN",
]);
}
async function setWebhookConfiguration(
setting: string,
topicArn: string,
eventTypes: EventType[]
) {
const sesConfigurationGeneral = await AppSettingsService.getSetting(setting);
if (!sesConfigurationGeneral) {
console.log(`Setting up SES webhook configuration for ${setting}`);
const status = await addWebhookConfiguration(setting, topicArn, eventTypes);
await AppSettingsService.setSetting(setting, status.toString());
}
}

View File

@@ -5,7 +5,7 @@ import {
} from "@aws-sdk/client-sns";
import { env } from "~/env";
function getSnsClient(region = "us-east-1") {
function getSnsClient(region: string) {
return new SNSClient({
region: region,
credentials: {
@@ -15,8 +15,8 @@ function getSnsClient(region = "us-east-1") {
});
}
export async function createTopic(topic: string) {
const client = getSnsClient();
export async function createTopic(topic: string, region: string) {
const client = getSnsClient(region);
const command = new CreateTopicCommand({
Name: topic,
});
@@ -25,13 +25,17 @@ export async function createTopic(topic: string) {
return data.TopicArn;
}
export async function subscribeEndpoint(topicArn: string, endpointUrl: string) {
export async function subscribeEndpoint(
topicArn: string,
endpointUrl: string,
region: string
) {
const subscribeCommand = new SubscribeCommand({
Protocol: "https",
TopicArn: topicArn,
Endpoint: endpointUrl,
});
const client = getSnsClient();
const client = getSnsClient(region);
const data = await client.send(subscribeCommand);
console.log(data.SubscriptionArn);

View File

@@ -1,7 +1,6 @@
import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
import { getTeamFromToken } from "~/server/public-api/auth";
import { sendEmail } from "~/server/service/email-service";
import { db } from "~/server/db";
import { EmailStatus } from "@prisma/client";
import { UnsendApiError } from "../../api-error";
@@ -30,7 +29,10 @@ const route = createRoute({
schema: z.object({
id: z.string(),
teamId: z.number(),
to: z.string(),
to: z.string().or(z.array(z.string())),
replyTo: z.string().or(z.array(z.string())).optional(),
cc: z.string().or(z.array(z.string())).optional(),
bcc: z.string().or(z.array(z.string())).optional(),
from: z.string(),
subject: z.string(),
html: z.string().nullable(),

View File

@@ -12,10 +12,12 @@ const route = createRoute({
content: {
"application/json": {
schema: z.object({
to: z.string().email(),
from: z.string().email(),
to: z.string().or(z.array(z.string())),
from: z.string(),
subject: z.string(),
replyTo: z.string().optional(),
replyTo: z.string().or(z.array(z.string())).optional(),
cc: z.string().or(z.array(z.string())).optional(),
bcc: z.string().or(z.array(z.string())).optional(),
text: z.string().optional(),
html: z.string().optional(),
attachments: z

View File

@@ -15,7 +15,7 @@ export function getApp() {
version: "1.0.0",
title: "Unsend API",
},
servers: [{ url: `${env.APP_URL}/api` }],
servers: [{ url: `${env.NEXTAUTH_URL}/api` }],
}));
app.openAPIRegistry.registerComponent("securitySchemes", "Bearer", {

View File

@@ -0,0 +1,11 @@
import IORedis from "ioredis";
import { env } from "~/env";
export let connection: IORedis | null = null;
export const getRedis = () => {
if (!connection) {
connection = new IORedis(env.REDIS_URL, { maxRetriesPerRequest: null });
}
return connection;
};

View File

@@ -1,38 +0,0 @@
import { db } from "../db";
import { JsonValue } from "@prisma/client/runtime/library";
export class AppSettingsService {
private static cache: Record<string, JsonValue> = {};
public static async getSetting(key: string) {
if (!this.cache[key]) {
const setting = await db.appSetting.findUnique({
where: { key },
});
if (setting) {
this.cache[key] = setting.value;
} else {
return null;
}
}
return this.cache[key];
}
public static async setSetting(key: string, value: string) {
await db.appSetting.upsert({
where: { key },
update: { value },
create: { key, value },
});
this.cache[key] = value;
return value;
}
public static async initializeCache(): Promise<void> {
const settings = await db.appSetting.findMany();
settings.forEach((setting) => {
this.cache[setting.key] = setting.value;
});
}
}

View File

@@ -3,18 +3,29 @@ import util from "util";
import * as tldts from "tldts";
import * as ses from "~/server/aws/ses";
import { db } from "~/server/db";
import { SesSettingsService } from "./ses-settings-service";
const dnsResolveTxt = util.promisify(dns.resolveTxt);
export async function createDomain(teamId: number, name: string) {
export async function createDomain(
teamId: number,
name: string,
region: string
) {
const domainStr = tldts.getDomain(name);
if (!domainStr) {
throw new Error("Invalid domain");
}
const setting = await SesSettingsService.getSetting(region);
if (!setting) {
throw new Error("Ses setting not found");
}
const subdomain = tldts.getSubdomain(name);
const publicKey = await ses.addDomain(name);
const publicKey = await ses.addDomain(name, region);
const domain = await db.domain.create({
data: {
@@ -22,6 +33,7 @@ export async function createDomain(teamId: number, name: string) {
publicKey,
teamId,
subdomain,
region,
},
});

View File

@@ -0,0 +1,135 @@
import { Job, Queue, Worker } from "bullmq";
import { env } from "~/env";
import { EmailAttachment } from "~/types";
import { getConfigurationSetName } from "~/utils/ses-utils";
import { db } from "../db";
import { sendEmailThroughSes, sendEmailWithAttachments } from "../aws/ses";
import { getRedis } from "../redis";
export class EmailQueueService {
private static initialized = false;
private static regionQueue = new Map<string, Queue>();
private static regionWorker = new Map<string, Worker>();
public static initializeQueue(region: string, quota: number) {
const connection = getRedis();
console.log(`[EmailQueueService]: Initializing queue for region ${region}`);
const queueName = `${region}-transaction`;
const queue = new Queue(queueName, { connection });
const worker = new Worker(queueName, executeEmail, {
limiter: {
max: quota,
duration: 1000,
},
concurrency: quota,
connection,
});
this.regionQueue.set(region, queue);
this.regionWorker.set(region, worker);
}
public static async queueEmail(emailId: string, region: string) {
if (!this.initialized) {
await this.init();
}
const queue = this.regionQueue.get(region);
if (!queue) {
throw new Error(`Queue for region ${region} not found`);
}
queue.add("send-email", { emailId, timestamp: Date.now() });
}
public static async init() {
const sesSettings = await db.sesSetting.findMany();
for (const sesSetting of sesSettings) {
this.initializeQueue(sesSetting.region, sesSetting.sesEmailRateLimit);
}
this.initialized = true;
}
}
async function executeEmail(job: Job<{ emailId: string; timestamp: number }>) {
console.log(
`[EmailQueueService]: Executing email job ${job.data.emailId}, time elapsed: ${Date.now() - job.data.timestamp}ms`
);
const email = await db.email.findUnique({
where: { id: job.data.emailId },
});
const domain = email?.domainId
? await db.domain.findUnique({
where: { id: email?.domainId },
})
: null;
if (!email) {
console.log(`[EmailQueueService]: Email not found, skipping`);
return;
}
const attachments: Array<EmailAttachment> = email.attachments
? JSON.parse(email.attachments)
: [];
const configurationSetName = await getConfigurationSetName(
domain?.clickTracking ?? false,
domain?.openTracking ?? false,
domain?.region ?? env.AWS_DEFAULT_REGION
);
if (!configurationSetName) {
console.log(`[EmailQueueService]: Configuration set not found, skipping`);
return;
}
console.log(`[EmailQueueService]: Sending email ${email.id}`);
try {
const messageId = attachments.length
? await sendEmailWithAttachments({
to: email.to,
from: email.from,
subject: email.subject,
text: email.text ?? undefined,
html: email.html ?? undefined,
region: domain?.region ?? env.AWS_DEFAULT_REGION,
configurationSetName,
attachments,
})
: await sendEmailThroughSes({
to: email.to,
from: email.from,
subject: email.subject,
replyTo: email.replyTo ?? undefined,
text: email.text ?? undefined,
html: email.html ?? undefined,
region: domain?.region ?? env.AWS_DEFAULT_REGION,
configurationSetName,
attachments,
});
// Delete attachments after sending the email
await db.email.update({
where: { id: email.id },
data: { sesEmailId: messageId, attachments: undefined },
});
} catch (error: any) {
await db.emailEvent.create({
data: {
emailId: email.id,
status: "FAILED",
data: {
error: error.toString(),
},
},
});
await db.email.update({
where: { id: email.id },
data: { latestStatus: "FAILED" },
});
}
}

View File

@@ -1,13 +1,23 @@
import { EmailContent } from "~/types";
import { db } from "../db";
import { UnsendApiError } from "~/server/public-api/api-error";
import { queueEmail } from "./job-service";
import { EmailQueueService } from "./email-queue-service";
export async function sendEmail(
emailContent: EmailContent & { teamId: number }
) {
const { to, from, subject, text, html, teamId, attachments, replyTo } =
emailContent;
const {
to,
from,
subject,
text,
html,
teamId,
attachments,
replyTo,
cc,
bcc,
} = emailContent;
const fromDomain = from.split("@")[1];
@@ -32,10 +42,16 @@ export async function sendEmail(
const email = await db.email.create({
data: {
to,
to: Array.isArray(to) ? to : [to],
from,
subject,
replyTo,
replyTo: replyTo
? Array.isArray(replyTo)
? replyTo
: [replyTo]
: undefined,
cc: cc ? (Array.isArray(cc) ? cc : [cc]) : undefined,
bcc: bcc ? (Array.isArray(bcc) ? bcc : [bcc]) : undefined,
text,
html,
teamId,
@@ -44,7 +60,24 @@ export async function sendEmail(
},
});
queueEmail(email.id);
try {
await EmailQueueService.queueEmail(email.id, domain.region);
} catch (error: any) {
await db.emailEvent.create({
data: {
emailId: email.id,
status: "FAILED",
data: {
error: error.toString(),
},
},
});
await db.email.update({
where: { id: email.id },
data: { latestStatus: "FAILED" },
});
throw error;
}
return email;
}

View File

@@ -1,109 +0,0 @@
import pgBoss from "pg-boss";
import { env } from "~/env";
import { EmailAttachment } from "~/types";
import { db } from "~/server/db";
import {
sendEmailThroughSes,
sendEmailWithAttachments,
} from "~/server/aws/ses";
import { getConfigurationSetName } from "~/utils/ses-utils";
import { sendToDiscord } from "./notification-service";
const boss = new pgBoss({
connectionString: env.DATABASE_URL,
archiveCompletedAfterSeconds: 60 * 60 * 24, // 24 hours
deleteAfterDays: 7, // 7 days
});
let started = false;
export async function getBoss() {
if (!started) {
await boss.start();
await boss.work(
"send_email",
{
teamConcurrency: env.SES_QUEUE_LIMIT,
teamSize: env.SES_QUEUE_LIMIT,
teamRefill: true,
},
executeEmail
);
boss.on("error", async (error) => {
console.error(error);
sendToDiscord(
`Error in pg-boss: ${error.name} \n ${error.cause}\n ${error.message}\n ${error.stack}`
);
await boss.stop();
started = false;
});
started = true;
}
return boss;
}
export async function queueEmail(emailId: string) {
const boss = await getBoss();
await boss.send("send_email", { emailId, timestamp: Date.now() });
}
async function executeEmail(
job: pgBoss.Job<{ emailId: string; timestamp: number }>
) {
console.log(
`[EmailJob]: Executing email job ${job.data.emailId}, time elapsed: ${Date.now() - job.data.timestamp}ms`
);
const email = await db.email.findUnique({
where: { id: job.data.emailId },
});
const domain = email?.domainId
? await db.domain.findUnique({
where: { id: email?.domainId },
})
: null;
if (!email) {
console.log(`[EmailJob]: Email not found, skipping`);
return;
}
const attachments: Array<EmailAttachment> = email.attachments
? JSON.parse(email.attachments)
: [];
const messageId = attachments.length
? await sendEmailWithAttachments({
to: email.to,
from: email.from,
subject: email.subject,
text: email.text ?? undefined,
html: email.html ?? undefined,
region: domain?.region ?? env.AWS_DEFAULT_REGION,
configurationSetName: getConfigurationSetName(
domain?.clickTracking ?? false,
domain?.openTracking ?? false
),
attachments,
})
: await sendEmailThroughSes({
to: email.to,
from: email.from,
subject: email.subject,
replyTo: email.replyTo ?? undefined,
text: email.text ?? undefined,
html: email.html ?? undefined,
region: domain?.region ?? env.AWS_DEFAULT_REGION,
configurationSetName: getConfigurationSetName(
domain?.clickTracking ?? false,
domain?.openTracking ?? false
),
attachments,
});
await db.email.update({
where: { id: email.id },
data: { sesEmailId: messageId, attachments: undefined },
});
}

View File

@@ -2,6 +2,8 @@ import { EmailStatus } from "@prisma/client";
import { SesEvent, SesEventDataKey } from "~/types/aws-types";
import { db } from "../db";
const STATUS_LIST = Object.values(EmailStatus);
export async function parseSesHook(data: SesEvent) {
const mailStatus = getEmailStatus(data);
@@ -30,21 +32,12 @@ export async function parseSesHook(data: SesEvent) {
id: email.id,
},
data: {
latestStatus: mailStatus,
latestStatus: getLatestStatus(email.latestStatus, mailStatus),
},
});
await db.emailEvent.upsert({
where: {
emailId_status: {
emailId: email.id,
status: mailStatus,
},
},
update: {
data: mailData as any,
},
create: {
await db.emailEvent.create({
data: {
emailId: email.id,
status: mailStatus,
data: mailData as any,
@@ -89,3 +82,12 @@ function getEmailData(data: SesEvent) {
return data[eventType.toLowerCase() as SesEventDataKey];
}
}
function getLatestStatus(
currentEmailStatus: EmailStatus,
incomingStatus: EmailStatus
) {
const index = STATUS_LIST.indexOf(currentEmailStatus);
const incomingIndex = STATUS_LIST.indexOf(incomingStatus);
return STATUS_LIST[Math.max(index, incomingIndex)] ?? incomingStatus;
}

View File

@@ -5,8 +5,9 @@ import { customAlphabet } from "nanoid";
import * as sns from "~/server/aws/sns";
import * as ses from "~/server/aws/ses";
import { EventType } from "@aws-sdk/client-sesv2";
import { EmailQueueService } from "./email-queue-service";
const nanoid = customAlphabet("1234567890abcdef", 10);
const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 10);
const GENERAL_EVENTS: EventType[] = [
"BOUNCE",
@@ -21,15 +22,26 @@ const GENERAL_EVENTS: EventType[] = [
export class SesSettingsService {
private static cache: Record<string, SesSetting> = {};
private static topicArns: Array<string> = [];
private static initialized = false;
public static getSetting(region = env.AWS_DEFAULT_REGION): SesSetting | null {
public static async getSetting(
region = env.AWS_DEFAULT_REGION
): Promise<SesSetting | null> {
await this.checkInitialized();
if (this.cache[region]) {
return this.cache[region] as SesSetting;
}
return null;
}
public static getAllSettings() {
public static async getTopicArns() {
await this.checkInitialized();
return this.topicArns;
}
public static async getAllSettings() {
await this.checkInitialized();
return Object.values(this.cache);
}
@@ -46,15 +58,20 @@ export class SesSettingsService {
region: string;
unsendUrl: string;
}) {
await this.checkInitialized();
if (this.cache[region]) {
throw new Error(`SesSetting for region ${region} already exists`);
}
const unsendUrlValidation = await isValidUnsendUrl(unsendUrl);
const parsedUrl = unsendUrl.endsWith("/")
? unsendUrl.substring(0, unsendUrl.length - 1)
: unsendUrl;
const unsendUrlValidation = await isValidUnsendUrl(parsedUrl);
if (!unsendUrlValidation.isValid) {
throw new Error(
`Unsend URL ${unsendUrl} is not valid, status: ${unsendUrlValidation.code} ${unsendUrlValidation.error}`
`Unsend URL: ${unsendUrl} is not valid, status: ${unsendUrlValidation.code} message:${unsendUrlValidation.error}`
);
}
@@ -63,28 +80,35 @@ export class SesSettingsService {
const setting = await db.sesSetting.create({
data: {
region,
callbackUrl: `${unsendUrl}/api/ses_callback`,
callbackUrl: `${parsedUrl}/api/ses_callback`,
topic: `${idPrefix}-${region}-unsend`,
idPrefix,
},
});
await createSettingInAws(setting);
EmailQueueService.initializeQueue(region, setting.sesEmailRateLimit);
this.invalidateCache();
await this.invalidateCache();
}
public static async init() {
public static async checkInitialized() {
if (!this.initialized) {
await this.invalidateCache();
this.initialized = true;
}
}
static async invalidateCache() {
this.cache = {};
const settings = await db.sesSetting.findMany();
settings.forEach((setting) => {
this.cache[setting.region] = setting;
if (setting.topicArn) {
this.topicArns.push(setting.topicArn);
}
});
}
static invalidateCache() {
this.cache = {};
this.init();
}
}
async function createSettingInAws(setting: SesSetting) {
@@ -95,18 +119,13 @@ async function createSettingInAws(setting: SesSetting) {
* Creates a new topic in AWS and subscribes the callback URL to it
*/
async function registerTopicInAws(setting: SesSetting) {
const topicArn = await sns.createTopic(setting.topic);
const topicArn = await sns.createTopic(setting.topic, setting.region);
if (!topicArn) {
throw new Error("Failed to create SNS topic");
}
await sns.subscribeEndpoint(
topicArn,
`${setting.callbackUrl}/api/ses_callback`
);
return await db.sesSetting.update({
const _setting = await db.sesSetting.update({
where: {
id: setting.id,
},
@@ -114,6 +133,17 @@ async function registerTopicInAws(setting: SesSetting) {
topicArn,
},
});
// Invalidate the cache to update the topicArn list
SesSettingsService.invalidateCache();
await sns.subscribeEndpoint(
topicArn,
`${setting.callbackUrl}`,
setting.region
);
return _setting;
}
/**
@@ -133,28 +163,32 @@ async function registerConfigurationSet(setting: SesSetting) {
const generalStatus = await ses.addWebhookConfiguration(
configGeneral,
setting.topicArn,
GENERAL_EVENTS
GENERAL_EVENTS,
setting.region
);
const configClick = `${setting.idPrefix}-${setting.region}-unsend-click`;
const clickStatus = await ses.addWebhookConfiguration(
configClick,
setting.topicArn,
[...GENERAL_EVENTS, "CLICK"]
[...GENERAL_EVENTS, "CLICK"],
setting.region
);
const configOpen = `${setting.idPrefix}-${setting.region}-unsend-open`;
const openStatus = await ses.addWebhookConfiguration(
configOpen,
setting.topicArn,
[...GENERAL_EVENTS, "OPEN"]
[...GENERAL_EVENTS, "OPEN"],
setting.region
);
const configFull = `${setting.idPrefix}-${setting.region}-unsend-full`;
const fullStatus = await ses.addWebhookConfiguration(
configFull,
setting.topicArn,
[...GENERAL_EVENTS, "CLICK", "OPEN"]
[...GENERAL_EVENTS, "CLICK", "OPEN"],
setting.region
);
return await db.sesSetting.update({
@@ -175,10 +209,10 @@ async function registerConfigurationSet(setting: SesSetting) {
}
async function isValidUnsendUrl(url: string) {
console.log("Checking if URL is valid", url);
try {
const response = await fetch(`${url}/api/ses_callback`, {
method: "POST",
body: JSON.stringify({ fromUnsend: true }),
method: "GET",
});
return {
isValid: response.status === 200,
@@ -186,6 +220,7 @@ async function isValidUnsendUrl(url: string) {
error: response.statusText,
};
} catch (e) {
console.log("Error checking if URL is valid", e);
return {
isValid: false,
code: 500,