Improve self host support (#28)

* Add docker setup for self hosting

* Add ses settings tables
This commit is contained in:
KM Koushik
2024-06-10 17:40:42 +10:00
committed by GitHub
parent 6128f26a78
commit 18b523912d
24 changed files with 708 additions and 169 deletions

View File

@@ -1,25 +0,0 @@
# Since the ".env" file is gitignored, you can use the ".env.example" file to
# build a new ".env" file when you clone the repo. Keep this file up-to-date
# when you add new variables to `.env`.
# This file will be committed to version control, so make sure not to have any
# secrets in it. If you are cloning this repo, create a copy of this file named
# ".env" and populate it with your secrets.
# When adding additional environment variables, the schema in "/src/env.js"
# should be updated accordingly.
# Prisma
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
DATABASE_URL="postgresql://postgres:password@localhost:5432/web"
# Next Auth
# You can generate a new secret on the command line with:
# openssl rand -base64 32
# https://next-auth.js.org/configuration/options#secret
# NEXTAUTH_SECRET=""
NEXTAUTH_URL="http://localhost:3000"
# Next Auth Discord Provider
DISCORD_CLIENT_ID=""
DISCORD_CLIENT_SECRET=""

View File

@@ -5,6 +5,8 @@
await import("./src/env.js");
/** @type {import("next").NextConfig} */
const config = {};
const config = {
output: process.env.DOCKER_OUTPUT ? "standalone" : undefined,
};
export default config;

View File

@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "next dev -p 3000",
"build": "pnpm db:migrate-deploy && next build",
"build": "next build",
"start": "next start",
"lint": "eslint . --max-warnings 0",
"db:post-install": "prisma generate",
@@ -37,6 +37,7 @@
"install": "^0.13.0",
"lucide-react": "^0.359.0",
"mime-types": "^2.1.35",
"nanoid": "^5.0.7",
"next": "^14.2.1",
"next-auth": "^4.24.6",
"pg-boss": "^9.0.3",

View File

@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "SesSetting" (
"id" TEXT NOT NULL,
"region" TEXT NOT NULL,
"idPrefix" TEXT NOT NULL,
"topic" TEXT NOT NULL,
"topicArn" TEXT,
"callbackUrl" TEXT NOT NULL,
"callbackSuccess" BOOLEAN NOT NULL DEFAULT false,
"configGeneral" TEXT,
"configGeneralSuccess" BOOLEAN NOT NULL DEFAULT false,
"configClick" TEXT,
"configClickSuccess" BOOLEAN NOT NULL DEFAULT false,
"configOpen" TEXT,
"configOpenSuccess" BOOLEAN NOT NULL DEFAULT false,
"configFull" TEXT,
"configFullSuccess" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SesSetting_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SesSetting_region_key" ON "SesSetting"("region");

View File

@@ -19,6 +19,26 @@ model AppSetting {
value String
}
model SesSetting {
id String @id @default(cuid())
region String @unique
idPrefix String
topic String
topicArn String?
callbackUrl String
callbackSuccess Boolean @default(false)
configGeneral String?
configGeneralSuccess Boolean @default(false)
configClick String?
configClickSuccess Boolean @default(false)
configOpen String?
configOpenSuccess Boolean @default(false)
configFull String?
configFullSuccess Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Necessary for Next auth
model Account {
id String @id @default(cuid())

View File

@@ -40,6 +40,12 @@ export default function ApiList() {
/>
</TableCell>
</TableRow>
) : apiKeysQuery.data?.length === 0 ? (
<TableRow className="h-32">
<TableCell colSpan={6} className="text-center py-4">
<p>No API keys added</p>
</TableCell>
</TableRow>
) : (
apiKeysQuery.data?.map((apiKey) => (
<TableRow key={apiKey.id}>

View File

@@ -8,6 +8,7 @@ import {
Home,
LayoutDashboard,
LineChart,
LogOut,
Mail,
Menu,
Package,
@@ -28,7 +29,7 @@ import {
} from "@unsend/ui/src/dropdown-menu";
import { Sheet, SheetContent, SheetTrigger } from "@unsend/ui/src/sheet";
import { NavButton } from "./nav-button";
import { LogoutButton, NavButton } from "./nav-button";
import { DashboardProvider } from "~/providers/dashboard-provider";
import { NextAuthProvider } from "~/providers/next-auth";
@@ -89,15 +90,16 @@ export default function AuthenticatedDashboardLayout({
Developer settings
</NavButton>
</div>
<div className=" absolute bottom-10 p-4">
<div className=" absolute bottom-10 p-4 flex flex-col gap-2">
<Link
href="https://docs.unsend.dev"
target="_blank"
className="flex gap-2 items-center"
className="flex gap-2 items-center hover:text-primary text-muted-foreground"
>
<BookOpenText className="h-4 w-4" />
<span className="">Docs</span>
</Link>
<LogoutButton />
</div>
</nav>
</div>

View File

@@ -1,5 +1,7 @@
"use client";
import { LogOut } from "lucide-react";
import { signOut } from "next-auth/react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import React from "react";
@@ -37,3 +39,15 @@ export const NavButton: React.FC<{
</Link>
);
};
export const LogoutButton: React.FC = () => {
return (
<button
className={` w-full justify-start flex items-center gap-2 rounded-lg py-2 transition-all hover:text-primary text-muted-foreground`}
onClick={() => signOut()}
>
<LogOut className="h-4 w-4" />
Logout
</button>
);
};

View File

@@ -1,3 +1,4 @@
import { db } from "~/server/db";
import { AppSettingsService } from "~/server/service/app-settings-service";
import { parseSesHook } from "~/server/service/ses-hook-parser";
import { SnsNotificationMessage } from "~/types/aws-types";
@@ -13,6 +14,10 @@ export async function POST(req: Request) {
console.log(data, data.Message);
if (isFromUnsend(data)) {
return Response.json({ data: "success" });
}
const isEventValid = await checkEventValidity(data);
console.log("isEventValid: ", isEventValid);
@@ -47,9 +52,38 @@ async function handleSubscription(message: any) {
method: "GET",
});
const topicArn = message.TopicArn as string;
const setting = await db.sesSetting.findFirst({
where: {
topicArn,
},
});
if (!setting) {
return Response.json({ data: "Setting not found" });
}
await db.sesSetting.update({
where: {
id: setting?.id,
},
data: {
callbackSuccess: true,
},
});
return Response.json({ data: "Success" });
}
// A simple check to ensure that the event is from the correct topic
function isFromUnsend({ fromUnsend }: { fromUnsend: boolean }) {
if (fromUnsend) {
return true;
}
return false;
}
// A simple check to ensure that the event is from the correct topic
async function checkEventValidity(message: SnsNotificationMessage) {
const { TopicArn } = message;

View File

@@ -6,6 +6,7 @@ import { Toaster } from "@unsend/ui/src/toaster";
import { TRPCReactProvider } from "~/trpc/react";
import { Metadata } from "next";
import { getBoss } from "~/server/service/job-service";
const inter = Inter({
subsets: ["latin"],
@@ -18,11 +19,17 @@ export const metadata: Metadata = {
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
export default function RootLayout({
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
/**
* Because I don't know a better way to call this during server startup.
* This is a temporary fix to ensure that the boss is running.
*/
await getBoss();
return (
<html lang="en">
<body className={`font-sans ${inter.variable}`}>

View File

@@ -22,6 +22,7 @@ import {
REGEXP_ONLY_DIGITS_AND_CHARS,
} from "@unsend/ui/src/input-otp";
import { Input } from "@unsend/ui/src/input";
import { env } from "~/env";
const emailSchema = z.object({
email: z
@@ -93,108 +94,129 @@ export default function LoginPage() {
</svg>
Continue with Github
</Button>
<Button
className="w-[350px]"
size="lg"
onClick={() => signIn("google")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 488 512"
className="h-6 w-6 stroke-black fill-black mr-4"
{env.NEXT_PUBLIC_IS_CLOUD ? (
<Button
className="w-[350px]"
size="lg"
onClick={() => signIn("google")}
>
<path d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z" />
</svg>
Continue with Google
</Button>
<div className=" flex w-[350px] items-center justify-between gap-2">
<p className=" z-10 ml-[175px] -translate-x-1/2 bg-background px-4 text-sm">
or
</p>
<div className="absolute h-[1px] w-[350px] bg-gradient-to-r from-zinc-800 via-zinc-300 to-zinc-800"></div>
</div>
{emailStatus === "success" ? (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 488 512"
className="h-6 w-6 stroke-black fill-black mr-4"
>
<path d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z" />
</svg>
Continue with Google
</Button>
) : null}
{env.NEXT_PUBLIC_IS_CLOUD ? (
<>
<p className=" w-[350px] text-center text-sm">
We have sent an email with the OTP. Please check your inbox
</p>
<Form {...otpForm}>
<form
onSubmit={otpForm.handleSubmit(onOTPSubmit)}
className="space-y-4"
>
<FormField
control={otpForm.control}
name="otp"
render={({ field }) => (
<FormItem>
<FormControl>
<InputOTP
className="w-[350px]"
maxLength={5}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
inputMode="text"
{...field}
>
<InputOTPGroup>
<InputOTPSlot className="w-[70px]" index={0} />
<InputOTPSlot className="w-[70px]" index={1} />
<InputOTPSlot className="w-[70px]" index={2} />
<InputOTPSlot className="w-[70px]" index={3} />
<InputOTPSlot className="w-[70px]" index={4} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<div className=" flex w-[350px] items-center justify-between gap-2">
<p className=" z-10 ml-[175px] -translate-x-1/2 bg-background px-4 text-sm">
or
</p>
<div className="absolute h-[1px] w-[350px] bg-gradient-to-r from-zinc-800 via-zinc-300 to-zinc-800"></div>
</div>
{emailStatus === "success" ? (
<>
<p className=" w-[350px] text-center text-sm">
We have sent an email with the OTP. Please check your inbox
</p>
<Form {...otpForm}>
<form
onSubmit={otpForm.handleSubmit(onOTPSubmit)}
className="space-y-4"
>
<FormField
control={otpForm.control}
name="otp"
render={({ field }) => (
<FormItem>
<FormControl>
<InputOTP
className="w-[350px]"
maxLength={5}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
inputMode="text"
{...field}
>
<InputOTPGroup>
<InputOTPSlot
className="w-[70px]"
index={0}
/>
<InputOTPSlot
className="w-[70px]"
index={1}
/>
<InputOTPSlot
className="w-[70px]"
index={2}
/>
<InputOTPSlot
className="w-[70px]"
index={3}
/>
<InputOTPSlot
className="w-[70px]"
index={4}
/>
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
</FormItem>
)}
/>
<Button className="mt-6 w-[350px] bg-white hover:bg-gray-100 focus:bg-gray-100">
Submit
</Button>
</form>
</Form>
<Button className="mt-6 w-[350px] bg-white hover:bg-gray-100 focus:bg-gray-100">
Submit
</Button>
</form>
</Form>
</>
) : (
<>
<Form {...emailForm}>
<form
onSubmit={emailForm.handleSubmit(onEmailSubmit)}
className="space-y-4"
>
<FormField
control={emailForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="Enter your email"
className=" w-[350px]"
type="email"
{...field}
/>
</FormControl>
<FormDescription />
<FormMessage />
</FormItem>
)}
/>
<Button
className=" w-[350px] bg-white hover:bg-gray-100 focus:bg-gray-100"
type="submit"
disabled={emailStatus === "sending"}
>
{emailStatus === "sending"
? "Sending..."
: "Send magic link"}
</Button>
</form>
</Form>
</>
)}
</>
) : (
<>
<Form {...emailForm}>
<form
onSubmit={emailForm.handleSubmit(onEmailSubmit)}
className="space-y-4"
>
<FormField
control={emailForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="Enter your email"
className=" w-[350px]"
type="email"
{...field}
/>
</FormControl>
<FormDescription />
<FormMessage />
</FormItem>
)}
/>
<Button
className=" w-[350px] bg-white hover:bg-gray-100 focus:bg-gray-100"
type="submit"
disabled={emailStatus === "sending"}
>
{emailStatus === "sending"
? "Sending..."
: "Send magic link"}
</Button>
</form>
</Form>
</>
)}
) : null}
</div>
</div>
</main>

View File

@@ -34,16 +34,18 @@ export const env = createEnv({
AWS_SECRET_KEY: z.string(),
APP_URL: z.string().optional(),
SNS_TOPIC: z.string(),
UNSEND_API_KEY: z.string(),
UNSEND_URL: z.string(),
GOOGLE_CLIENT_ID: z.string(),
GOOGLE_CLIENT_SECRET: z.string(),
UNSEND_API_KEY: z.string().optional(),
UNSEND_URL: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
SES_QUEUE_LIMIT: z.string().transform((str) => parseInt(str, 10)),
AWS_DEFAULT_REGION: z.string().default("us-east-1"),
API_RATE_LIMIT: z
.string()
.transform((str) => parseInt(str, 10))
.default(2),
FROM_EMAIL: z.string().optional(),
ADMIN_EMAIL: z.string().optional(),
},
/**
@@ -53,6 +55,7 @@ export const env = createEnv({
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
NEXT_PUBLIC_IS_CLOUD: z.string().transform((str) => str === "true"),
},
/**
@@ -77,12 +80,14 @@ export const env = createEnv({
SES_QUEUE_LIMIT: process.env.SES_QUEUE_LIMIT,
AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION,
API_RATE_LIMIT: process.env.API_RATE_LIMIT,
NEXT_PUBLIC_IS_CLOUD: process.env.NEXT_PUBLIC_IS_CLOUD,
ADMIN_EMAIL: process.env.ADMIN_EMAIL,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
skipValidation: process.env.SKIP_ENV_VALIDATION === "true",
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.

View File

@@ -0,0 +1,37 @@
import { z } from "zod";
import { env } from "~/env";
import { createTRPCRouter, adminProcedure } from "~/server/api/trpc";
import { SesSettingsService } from "~/server/service/ses-settings-service";
export const adminRouter = createTRPCRouter({
getSesSettings: adminProcedure.query(async () => {
return SesSettingsService.getAllSettings();
}),
addSesSettings: adminProcedure
.input(
z.object({
region: z.string(),
unsendUrl: z.string().url(),
})
)
.mutation(async ({ input }) => {
return SesSettingsService.createSesSetting({
region: input.region,
unsendUrl: input.unsendUrl,
});
}),
getSetting: adminProcedure
.input(
z.object({
region: z.string().optional().nullable(),
})
)
.query(async ({ input }) => {
return SesSettingsService.getSetting(
input.region ?? env.AWS_DEFAULT_REGION
);
}),
});

View File

@@ -10,6 +10,7 @@
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { env } from "~/env";
import { getServerAuthSession } from "~/server/auth";
import { db } from "~/server/db";
@@ -123,3 +124,13 @@ export const teamProcedure = protectedProcedure.use(async ({ ctx, next }) => {
},
});
});
/**
* To manage application settings, for hosted version, authenticated users will be considered as admin
*/
export const adminProcedure = protectedProcedure.use(async ({ ctx, next }) => {
if (env.NEXT_PUBLIC_IS_CLOUD && ctx.session.user.email !== env.ADMIN_EMAIL) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next();
});

View File

@@ -1,5 +1,6 @@
import { PrismaAdapter } from "@auth/prisma-adapter";
import {
AuthOptions,
getServerSession,
type DefaultSession,
type NextAuthOptions,
@@ -36,6 +37,42 @@ declare module "next-auth" {
}
}
/**
* Auth providers
*/
const providers: Provider[] = [
GitHubProvider({
clientId: env.GITHUB_ID,
clientSecret: env.GITHUB_SECRET,
allowDangerousEmailAccountLinking: true,
}),
];
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
providers.push(
GoogleProvider({
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
allowDangerousEmailAccountLinking: true,
})
);
}
if (env.FROM_EMAIL) {
providers.push(
EmailProvider({
from: env.FROM_EMAIL,
async sendVerificationRequest({ identifier: email, url, token }) {
await sendSignUpEmail(email, token, url);
},
async generateVerificationToken() {
return Math.random().toString(36).substring(2, 7).toLowerCase();
},
})
);
}
/**
* Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
*
@@ -56,36 +93,18 @@ export const authOptions: NextAuthOptions = {
pages: {
signIn: "/login",
},
providers: [
/**
* ...add more providers here.
*
* Most other providers require a bit more work than the Discord provider. For example, the
* GitHub provider requires you to add the `refresh_token_expires_in` field to the Account
* model. Refer to the NextAuth.js docs for the provider you want to use. Example:
*
* @see https://next-auth.js.org/providers/github
*/
GitHubProvider({
clientId: env.GITHUB_ID,
clientSecret: env.GITHUB_SECRET,
allowDangerousEmailAccountLinking: true,
}),
GoogleProvider({
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
allowDangerousEmailAccountLinking: true,
}),
EmailProvider({
from: "no-reply@splitpro.app",
async sendVerificationRequest({ identifier: email, url, token }) {
await sendSignUpEmail(email, token, url);
},
async generateVerificationToken() {
return Math.random().toString(36).substring(2, 7).toLowerCase();
},
}),
],
events: {
createUser: async ({ user }) => {
// No waitlist for self hosting
if (!env.NEXT_PUBLIC_IS_CLOUD) {
await db.user.update({
where: { id: user.id },
data: { isBetaUser: true },
});
}
},
},
providers,
};
/**
@@ -97,6 +116,7 @@ export const getServerAuthSession = () => getServerSession(authOptions);
import { createHash } from "crypto";
import { sendSignUpEmail } from "./mailer";
import { Provider } from "next-auth/providers/index";
/**
* Hashes a token using SHA-256.

View File

@@ -1,7 +1,14 @@
import { env } from "~/env";
import { Unsend } from "unsend";
const unsend = new Unsend(env.UNSEND_API_KEY);
let unsend: Unsend | undefined;
const getClient = () => {
if (!unsend) {
unsend = new Unsend(env.UNSEND_API_KEY);
}
return unsend;
};
export async function sendSignUpEmail(
email: string,
@@ -28,10 +35,10 @@ async function sendMail(
text: string,
html: string
) {
if (env.UNSEND_API_KEY && env.UNSEND_URL) {
const resp = await unsend.emails.send({
if (env.UNSEND_API_KEY && env.UNSEND_URL && env.FROM_EMAIL) {
const resp = await getClient().emails.send({
to: email,
from: "no-reply@auth.unsend.dev",
from: env.FROM_EMAIL,
subject,
text,
html,

View File

@@ -10,6 +10,8 @@ const rateLimitCache = new TTLCache({
max: env.API_RATE_LIMIT,
});
console.log(env.DATABASE_URL);
/**
* Gets the team from the token. Also will check if the token is valid.
*/

View File

@@ -15,7 +15,7 @@ const boss = new pgBoss({
});
let started = false;
async function getBoss() {
export async function getBoss() {
if (!started) {
await boss.start();
await boss.work(

View File

@@ -0,0 +1,195 @@
import { SesSetting } from "@prisma/client";
import { db } from "../db";
import { env } from "~/env";
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";
const nanoid = customAlphabet("1234567890abcdef", 10);
const GENERAL_EVENTS: EventType[] = [
"BOUNCE",
"COMPLAINT",
"DELIVERY",
"DELIVERY_DELAY",
"REJECT",
"RENDERING_FAILURE",
"SEND",
"SUBSCRIPTION",
];
export class SesSettingsService {
private static cache: Record<string, SesSetting> = {};
public static getSetting(region = env.AWS_DEFAULT_REGION): SesSetting | null {
if (this.cache[region]) {
return this.cache[region] as SesSetting;
}
return null;
}
public static getAllSettings() {
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,
}: {
region: string;
unsendUrl: string;
}) {
if (this.cache[region]) {
throw new Error(`SesSetting for region ${region} already exists`);
}
const unsendUrlValidation = await isValidUnsendUrl(unsendUrl);
if (!unsendUrlValidation.isValid) {
throw new Error(
`Unsend URL ${unsendUrl} is not valid, status: ${unsendUrlValidation.code} ${unsendUrlValidation.error}`
);
}
const idPrefix = nanoid(10);
const setting = await db.sesSetting.create({
data: {
region,
callbackUrl: `${unsendUrl}/api/ses_callback`,
topic: `${idPrefix}-${region}-unsend`,
idPrefix,
},
});
await createSettingInAws(setting);
this.invalidateCache();
}
public static async init() {
const settings = await db.sesSetting.findMany();
settings.forEach((setting) => {
this.cache[setting.region] = setting;
});
}
static invalidateCache() {
this.cache = {};
this.init();
}
}
async function createSettingInAws(setting: SesSetting) {
await registerTopicInAws(setting).then(registerConfigurationSet);
}
/**
* 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);
if (!topicArn) {
throw new Error("Failed to create SNS topic");
}
await sns.subscribeEndpoint(
topicArn,
`${setting.callbackUrl}/api/ses_callback`
);
return await db.sesSetting.update({
where: {
id: setting.id,
},
data: {
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
);
const configClick = `${setting.idPrefix}-${setting.region}-unsend-click`;
const clickStatus = await ses.addWebhookConfiguration(
configClick,
setting.topicArn,
[...GENERAL_EVENTS, "CLICK"]
);
const configOpen = `${setting.idPrefix}-${setting.region}-unsend-open`;
const openStatus = await ses.addWebhookConfiguration(
configOpen,
setting.topicArn,
[...GENERAL_EVENTS, "OPEN"]
);
const configFull = `${setting.idPrefix}-${setting.region}-unsend-full`;
const fullStatus = await ses.addWebhookConfiguration(
configFull,
setting.topicArn,
[...GENERAL_EVENTS, "CLICK", "OPEN"]
);
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) {
try {
const response = await fetch(`${url}/api/ses_callback`, {
method: "POST",
body: JSON.stringify({ fromUnsend: true }),
});
return {
isValid: response.status === 200,
code: response.status,
error: response.statusText,
};
} catch (e) {
return {
isValid: false,
code: 500,
error: e,
};
}
}