Improve self host support (#28)
* Add docker setup for self hosting * Add ses settings tables
This commit is contained in:
@@ -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}>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
||||
|
@@ -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;
|
||||
|
@@ -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}`}>
|
||||
|
@@ -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>
|
||||
|
@@ -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.
|
||||
|
37
apps/web/src/server/api/routers/admin.ts
Normal file
37
apps/web/src/server/api/routers/admin.ts
Normal 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
|
||||
);
|
||||
}),
|
||||
});
|
@@ -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();
|
||||
});
|
||||
|
@@ -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.
|
||||
|
@@ -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,
|
||||
|
@@ -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.
|
||||
*/
|
||||
|
@@ -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(
|
||||
|
195
apps/web/src/server/service/ses-settings-service.ts
Normal file
195
apps/web/src/server/service/ses-settings-service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user