Files
GibSend/apps/web/src/server/service/ses-settings-service.ts
KM Koushik f1186f875c enable teams for self-hosted (#137)
* enable teams for self-hosted

* remove console
2025-03-29 00:56:06 +11:00

283 lines
6.8 KiB
TypeScript

import { SesSetting } from "@prisma/client";
import { db } from "../db";
import { env } from "~/env";
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";
import { smallNanoid } from "../nanoid";
const GENERAL_EVENTS: EventType[] = [
"BOUNCE",
"COMPLAINT",
"DELIVERY",
"DELIVERY_DELAY",
"REJECT",
"RENDERING_FAILURE",
"SEND",
"SUBSCRIPTION",
];
export class SesSettingsService {
private static cache: Record<string, SesSetting> = {};
private static topicArns: Array<string> = [];
private static initialized = false;
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 async getTopicArns() {
await this.checkInitialized();
return this.topicArns;
}
public static async getAllSettings() {
await this.checkInitialized();
return Object.values(this.cache);
}
/**
* Creates a new setting in AWS for the given region and unsendUrl
*
* @param region
* @param unsendUrl
*/
public static async createSesSetting({
region,
unsendUrl,
sendingRateLimit,
transactionalQuota,
}: {
region: string;
unsendUrl: string;
sendingRateLimit: number;
transactionalQuota: number;
}) {
await this.checkInitialized();
if (this.cache[region]) {
throw new Error(`SesSetting for region ${region} already exists`);
}
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} message:${unsendUrlValidation.error}`
);
}
const idPrefix = smallNanoid(10);
let topicArn: string | undefined;
try {
const topicName = `${idPrefix}-${region}-unsend`;
topicArn = await sns.createTopic(topicName, region);
if (!topicArn) {
throw new Error("Failed to create SNS topic");
}
const setting = await db.$transaction(async (tx) => {
const setting = await tx.sesSetting.create({
data: {
region,
callbackUrl: `${parsedUrl}/api/ses_callback`,
topic: topicName,
topicArn,
sesEmailRateLimit: sendingRateLimit,
transactionalQuota,
idPrefix,
},
});
await sns.subscribeEndpoint(
topicArn!,
`${setting.callbackUrl}`,
setting.region
);
return setting;
});
if (!setting) {
throw new Error("Failed to create setting");
}
await registerConfigurationSet(setting);
EmailQueueService.initializeQueue(
region,
setting.sesEmailRateLimit,
setting.transactionalQuota
);
console.log(
EmailQueueService.transactionalQueue,
EmailQueueService.marketingQueue
);
await this.invalidateCache();
} catch (error) {
if (topicArn) {
try {
await sns.deleteTopic(topicArn, region);
} catch (deleteError) {
console.error("Failed to delete SNS topic after error:", deleteError);
}
}
console.error("Failed to create SES setting", error);
throw error;
}
}
public static async updateSesSetting({
id,
sendingRateLimit,
transactionalQuota,
}: {
id: string;
sendingRateLimit: number;
transactionalQuota: number;
}) {
await this.checkInitialized();
const setting = await db.sesSetting.update({
where: {
id,
},
data: {
transactionalQuota,
sesEmailRateLimit: sendingRateLimit,
},
});
console.log(
EmailQueueService.transactionalQueue,
EmailQueueService.marketingQueue
);
EmailQueueService.initializeQueue(
setting.region,
setting.sesEmailRateLimit,
setting.transactionalQuota
);
console.log(
EmailQueueService.transactionalQueue,
EmailQueueService.marketingQueue
);
await this.invalidateCache();
}
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);
}
});
}
}
/**
* Creates a new configuration set in AWS for given region
* Totally consist of 4 configs.
* 1. General - for general events
* 2. Click - for click tracking
* 3. Open - for open tracking
* 4. Full - for click and open tracking
*/
async function registerConfigurationSet(setting: SesSetting) {
if (!setting.topicArn) {
throw new Error("Setting does not have a topic ARN");
}
const configGeneral = `${setting.idPrefix}-${setting.region}-unsend-general`;
const generalStatus = await ses.addWebhookConfiguration(
configGeneral,
setting.topicArn,
GENERAL_EVENTS,
setting.region
);
const configClick = `${setting.idPrefix}-${setting.region}-unsend-click`;
const clickStatus = await ses.addWebhookConfiguration(
configClick,
setting.topicArn,
[...GENERAL_EVENTS, "CLICK"],
setting.region
);
const configOpen = `${setting.idPrefix}-${setting.region}-unsend-open`;
const openStatus = await ses.addWebhookConfiguration(
configOpen,
setting.topicArn,
[...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"],
setting.region
);
return await db.sesSetting.update({
where: {
id: setting.id,
},
data: {
configGeneral,
configGeneralSuccess: generalStatus,
configClick,
configClickSuccess: clickStatus,
configOpen,
configOpenSuccess: openStatus,
configFull,
configFullSuccess: fullStatus,
},
});
}
async function isValidUnsendUrl(url: string) {
console.log("Checking if URL is valid", url);
try {
const response = await fetch(`${url}/api/ses_callback`, {
method: "GET",
});
return {
isValid: response.status === 200,
code: response.status,
error: response.statusText,
};
} catch (e) {
console.log("Error checking if URL is valid", e);
return {
isValid: false,
code: 500,
error: e,
};
}
}