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

34
.dockerignore Normal file
View File

@@ -0,0 +1,34 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp
.pnp.js
# next.js
.next/
out/
build
# misc
.DS_Store
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# turbo
.turbo
# vercel
.vercel

30
.env.selfhost.example Normal file
View File

@@ -0,0 +1,30 @@
# Redis container name
REDIS_URL="redis://redis:6379"
# Postgres
POSTGRES_USER="postgres"
POSTGRES_PASSWORD="postgres"
POSTGRES_DB="unsend"
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/unsend"
# NextAuth
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET=
# Github login
GITHUB_ID="<your-github-client-id>"
GITHUB_SECRET="<your-github-client-secret>"
# AWS details
AWS_DEFAULT_REGION="us-east-1"
AWS_SECRET_KEY="<your-aws-secret-key>"
AWS_ACCESS_KEY="<your-aws-access-key>"
DOCKER_OUTPUT=1
NEXT_PUBLIC_IS_CLOUD=false
API_RATE_LIMIT=1
# used to send important error notification
DISCORD_WEBHOOK_URL=""

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
.env.development.local
.env.test.local
.env.production.local
.env.selfhost
# Testing
coverage

View File

@@ -6,7 +6,7 @@
},
"servers": [
{
"url": "https://app.unsend.dev/api"
"url": "https://test.ossapps.dev/api"
}
],
"components": {
@@ -111,7 +111,7 @@
"schema": {
"type": "string",
"minLength": 3,
"example": "1212121"
"example": "cuiwqdj74rygf74"
},
"required": true,
"name": "emailId",
@@ -133,7 +133,56 @@
"type": "number"
},
"to": {
"type": "string"
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"replyTo": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"cc": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"bcc": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"from": {
"type": "string"
@@ -222,8 +271,19 @@
"type": "object",
"properties": {
"to": {
"type": "string",
"format": "email"
"anyOf": [
{
"type": "string",
"format": "email"
},
{
"type": "array",
"items": {
"type": "string",
"format": "email"
}
}
]
},
"from": {
"type": "string",
@@ -233,7 +293,49 @@
"type": "string"
},
"replyTo": {
"type": "string"
"anyOf": [
{
"type": "string",
"format": "email"
},
{
"type": "array",
"items": {
"type": "string",
"format": "email"
}
}
]
},
"cc": {
"anyOf": [
{
"type": "string",
"format": "email"
},
{
"type": "array",
"items": {
"type": "string",
"format": "email"
}
}
]
},
"bcc": {
"anyOf": [
{
"type": "string",
"format": "email"
},
{
"type": "array",
"items": {
"type": "string",
"format": "email"
}
}
]
},
"text": {
"type": "string"

View File

@@ -7,6 +7,11 @@ await import("./src/env.js");
/** @type {import("next").NextConfig} */
const config = {
output: process.env.DOCKER_OUTPUT ? "standalone" : undefined,
experimental: {
instrumentationHook: true,
esmExternals: "loose",
serverComponentsExternalPackages: ["bullmq"],
},
};
export default config;

View File

@@ -13,7 +13,8 @@
"db:push": "prisma db push --skip-generate",
"db:migrate-dev": "prisma migrate dev",
"db:migrate-deploy": "prisma migrate deploy",
"db:studio": "prisma studio"
"db:studio": "prisma studio",
"db:migrate-reset": "prisma migrate reset"
},
"dependencies": {
"@auth/prisma-adapter": "^1.4.0",
@@ -32,9 +33,11 @@
"@trpc/react-query": "next",
"@trpc/server": "next",
"@unsend/ui": "workspace:*",
"bullmq": "^5.8.2",
"date-fns": "^3.6.0",
"hono": "^4.2.2",
"install": "^0.13.0",
"ioredis": "^5.4.1",
"lucide-react": "^0.359.0",
"mime-types": "^2.1.35",
"nanoid": "^5.0.7",
@@ -51,6 +54,7 @@
"server-only": "^0.0.1",
"superjson": "^2.2.1",
"tldts": "^6.1.16",
"ua-parser-js": "^1.0.38",
"unsend": "workspace:*",
"zod": "^3.22.4"
},
@@ -61,6 +65,7 @@
"@types/node": "^20.11.20",
"@types/react": "^18.2.57",
"@types/react-dom": "^18.2.19",
"@types/ua-parser-js": "^0.7.39",
"@typescript-eslint/eslint-plugin": "^7.1.1",
"@typescript-eslint/parser": "^7.1.1",
"@unsend/eslint-config": "workspace:*",

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isBetaUser" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -1,5 +0,0 @@
-- AlterEnum
ALTER TYPE "EmailStatus" ADD VALUE 'QUEUED';
-- AlterTable
ALTER TABLE "Email" ADD COLUMN "attachments" TEXT;

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Email" ALTER COLUMN "latestStatus" SET DEFAULT 'QUEUED';

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Email" ADD COLUMN "replyTo" TEXT;

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Domain" ADD COLUMN "isVerifying" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -1,25 +0,0 @@
-- 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

@@ -8,7 +8,7 @@ CREATE TYPE "DomainStatus" AS ENUM ('NOT_STARTED', 'PENDING', 'SUCCESS', 'FAILED
CREATE TYPE "ApiPermission" AS ENUM ('FULL', 'SENDING');
-- CreateEnum
CREATE TYPE "EmailStatus" AS ENUM ('SENT', 'OPENED', 'CLICKED', 'BOUNCED', 'COMPLAINED', 'DELIVERED', 'REJECTED', 'RENDERING_FAILURE', 'DELIVERY_DELAYED');
CREATE TYPE "EmailStatus" AS ENUM ('QUEUED', 'SENT', 'OPENED', 'CLICKED', 'BOUNCED', 'COMPLAINED', 'DELIVERED', 'REJECTED', 'RENDERING_FAILURE', 'DELIVERY_DELAYED', 'FAILED');
-- CreateTable
CREATE TABLE "AppSetting" (
@@ -18,6 +18,30 @@ CREATE TABLE "AppSetting" (
CONSTRAINT "AppSetting_pkey" PRIMARY KEY ("key")
);
-- 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,
"sesEmailRateLimit" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SesSetting_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
@@ -61,6 +85,7 @@ CREATE TABLE "User" (
"email" TEXT,
"emailVerified" TIMESTAMP(3),
"image" TEXT,
"isBetaUser" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
@@ -97,6 +122,7 @@ CREATE TABLE "Domain" (
"dmarcAdded" BOOLEAN NOT NULL DEFAULT false,
"errorMessage" TEXT,
"subdomain" TEXT,
"isVerifying" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
@@ -122,28 +148,38 @@ CREATE TABLE "ApiKey" (
CREATE TABLE "Email" (
"id" TEXT NOT NULL,
"sesEmailId" TEXT,
"to" TEXT NOT NULL,
"from" TEXT NOT NULL,
"to" TEXT[],
"replyTo" TEXT[],
"cc" TEXT[],
"bcc" TEXT[],
"subject" TEXT NOT NULL,
"text" TEXT,
"html" TEXT,
"latestStatus" "EmailStatus" NOT NULL DEFAULT 'SENT',
"latestStatus" "EmailStatus" NOT NULL DEFAULT 'QUEUED',
"teamId" INTEGER NOT NULL,
"domainId" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"attachments" TEXT,
CONSTRAINT "Email_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EmailEvent" (
"id" TEXT NOT NULL,
"emailId" TEXT NOT NULL,
"status" "EmailStatus" NOT NULL,
"data" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EmailEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SesSetting_region_key" ON "SesSetting"("region");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
@@ -171,9 +207,6 @@ CREATE UNIQUE INDEX "ApiKey_tokenHash_key" ON "ApiKey"("tokenHash");
-- CreateIndex
CREATE UNIQUE INDEX "Email_sesEmailId_key" ON "Email"("sesEmailId");
-- CreateIndex
CREATE UNIQUE INDEX "EmailEvent_emailId_status_key" ON "EmailEvent"("emailId", "status");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -35,6 +35,7 @@ model SesSetting {
configOpenSuccess Boolean @default(false)
configFull String?
configFullSuccess Boolean @default(false)
sesEmailRateLimit Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -162,23 +163,26 @@ model ApiKey {
enum EmailStatus {
QUEUED
SENT
BOUNCED
DELIVERED
OPENED
CLICKED
BOUNCED
COMPLAINED
DELIVERED
REJECTED
RENDERING_FAILURE
DELIVERY_DELAYED
FAILED
}
model Email {
id String @id @default(cuid())
sesEmailId String? @unique
to String
from String
to String[]
replyTo String[]
cc String[]
bcc String[]
subject String
replyTo String?
text String?
html String?
latestStatus EmailStatus @default(QUEUED)
@@ -192,11 +196,10 @@ model Email {
}
model EmailEvent {
id String @id @default(cuid())
emailId String
status EmailStatus
data Json?
createdAt DateTime @default(now())
email Email @relation(fields: [emailId], references: [id], onDelete: Cascade)
@@unique([emailId, status])
}

View File

@@ -0,0 +1,40 @@
"use client";
import { Button } from "@unsend/ui/src/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@unsend/ui/src/dialog";
import { Plus } from "lucide-react";
import { useState } from "react";
import { AddSesSettingsForm } from "~/components/settings/AddSesSettings";
export default function AddSesConfiguration() {
const [open, setOpen] = useState(false);
return (
<Dialog
open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-1" />
Add SES configuration
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add a new SES configuration</DialogTitle>
</DialogHeader>
<div className="py-2">
<AddSesSettingsForm onSuccess={() => setOpen(false)} />
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,19 @@
"use client";
import AddSesConfiguration from "./add-ses-configuration";
import SesConfigurations from "./ses-configurations";
export default function ApiKeysPage() {
return (
<div>
<div className="flex justify-between items-center">
<h1 className="font-bold text-lg">Admin</h1>
<AddSesConfiguration />
</div>
<div className="mt-10">
<p className="font-semibold mb-4">SES Configurations</p>
<SesConfigurations />
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@unsend/ui/src/table";
import { formatDistanceToNow } from "date-fns";
import { api } from "~/trpc/react";
import Spinner from "@unsend/ui/src/spinner";
export default function SesConfigurations() {
const sesSettingsQuery = api.admin.getSesSettings.useQuery();
return (
<div className="">
<div className="border rounded-xl">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
<TableHead className="rounded-tl-xl">Region</TableHead>
<TableHead>Callback URL</TableHead>
<TableHead>Callback status</TableHead>
<TableHead>Created at</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sesSettingsQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={6} className="text-center py-4">
<Spinner
className="w-6 h-6 mx-auto"
innerSvgClass="stroke-primary"
/>
</TableCell>
</TableRow>
) : sesSettingsQuery.data?.length === 0 ? (
<TableRow className="h-32">
<TableCell colSpan={6} className="text-center py-4">
<p>No SES configurations added</p>
</TableCell>
</TableRow>
) : (
sesSettingsQuery.data?.map((sesSetting) => (
<TableRow key={sesSetting.id}>
<TableCell>{sesSetting.region}</TableCell>
<TableCell>{sesSetting.callbackUrl}</TableCell>
<TableCell>
{sesSetting.callbackSuccess ? "Success" : "Failed"}
</TableCell>
<TableCell>
{formatDistanceToNow(sesSetting.createdAt)} ago
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,192 @@
"use client";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { LogoutButton, NavButton } from "./nav-button";
import {
BookOpenText,
BookUser,
CircleUser,
Code,
Globe,
Home,
LayoutDashboard,
LineChart,
Mail,
Menu,
Package,
Package2,
Server,
ShoppingCart,
Users,
Volume2,
} from "lucide-react";
import { env } from "~/env";
import { Sheet, SheetContent, SheetTrigger } from "@unsend/ui/src/sheet";
import { Button } from "@unsend/ui/src/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@unsend/ui/src/dropdown-menu";
export function DashboardLayout({ children }: { children: React.ReactNode }) {
const { data: session } = useSession();
return (
<div className="flex min-h-screen w-full h-full">
<div className="hidden bg-muted/20 md:block md:w-[280px]">
<div className="flex h-full max-h-screen flex-col gap-2">
<div className="flex h-14 gap-4 items-center px-4 lg:h-[60px] lg:px-6">
<Link href="/" className="flex items-center gap-2 font-semibold">
<span className=" text-lg">Unsend</span>
</Link>
<span className="text-[10px] text-muted-foreground bg-muted p-0.5 px-2 rounded-full">
Early access
</span>
</div>
<div className="flex-1 h-full">
<nav className=" flex-1 h-full flex-col justify-between items-center px-2 text-sm font-medium lg:px-4">
<div>
<NavButton href="/dashboard">
<LayoutDashboard className="h-4 w-4" />
Dashboard
</NavButton>
<NavButton href="/emails">
<Mail className="h-4 w-4" />
Emails
</NavButton>
<NavButton href="/domains">
<Globe className="h-4 w-4" />
Domains
</NavButton>
<NavButton href="/contacts" comingSoon>
<BookUser className="h-4 w-4" />
Contacts
</NavButton>
<NavButton href="/contacts" comingSoon>
<Volume2 className="h-4 w-4" />
Marketing
</NavButton>
<NavButton href="/api-keys">
<Code className="h-4 w-4" />
Developer settings
</NavButton>
{!env.NEXT_PUBLIC_IS_CLOUD || session?.user.isAdmin ? (
<NavButton href="/admin">
<Server className="h-4 w-4" />
Admin
</NavButton>
) : null}
</div>
<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 hover:text-primary text-muted-foreground"
>
<BookOpenText className="h-4 w-4" />
<span className="">Docs</span>
</Link>
<LogoutButton />
</div>
</nav>
</div>
<div className="mt-auto p-4"></div>
</div>
</div>
<div className="flex flex-1 flex-col">
<header className="flex h-14 items-center gap-4 md:hidden bg-muted/20 px-4 lg:h-[60px] lg:px-6">
<Sheet>
<SheetTrigger asChild>
<Button
variant="outline"
size="icon"
className="shrink-0 md:hidden"
>
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="flex flex-col">
<nav className="grid gap-2 text-lg font-medium">
<Link
href="#"
className="flex items-center gap-2 text-lg font-semibold"
>
<Package2 className="h-6 w-6" />
<span className="sr-only">Acme Inc</span>
</Link>
<Link
href="#"
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
>
<Home className="h-5 w-5" />
Dashboard
</Link>
<Link
href="#"
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl bg-muted px-3 py-2 text-foreground hover:text-foreground"
>
<ShoppingCart className="h-5 w-5" />
Orders
</Link>
<Link
href="#"
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
>
<Package className="h-5 w-5" />
Products
</Link>
<Link
href="#"
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
>
<Users className="h-5 w-5" />
Customers
</Link>
<Link
href="#"
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
>
<LineChart className="h-5 w-5" />
Analytics
</Link>
</nav>
<div className="mt-auto"></div>
</SheetContent>
</Sheet>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="rounded-full">
<CircleUser className="h-5 w-5" />
<span className="sr-only">Toggle user menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>Support</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</header>
<main className="flex-1 overflow-y-auto h-full">
<div className="flex flex-col gap-4 p-4 w-full lg:max-w-6xl mx-auto lg:gap-6 lg:p-6">
{children}
</div>
</main>
</div>
</div>
);
}

View File

@@ -26,6 +26,7 @@ import DeleteDomain from "./delete-domain";
import SendTestMail from "./send-test-mail";
import { Button } from "@unsend/ui/src/button";
import Link from "next/link";
import { toast } from "@unsend/ui/src/toaster";
export default function DomainItemPage({
params,
@@ -245,7 +246,8 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
{ id: domain.id, clickTracking: !clickTracking },
{
onSuccess: () => {
utils.domain.domains.invalidate();
utils.domain.invalidate();
toast.success("Click tracking updated");
},
}
);
@@ -257,7 +259,8 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
{ id: domain.id, openTracking: !openTracking },
{
onSuccess: () => {
utils.domain.domains.invalidate();
utils.domain.invalidate();
toast.success("Open tracking updated");
},
}
);

View File

@@ -14,6 +14,8 @@ import { Domain } from "@prisma/client";
import { toast } from "@unsend/ui/src/toaster";
import { SendHorizonal } from "lucide-react";
import { Code } from "@unsend/ui/src/code";
import { useSession } from "next-auth/react";
import { getSendTestEmailCode } from "~/lib/constants/example-codes";
const jsCode = `const requestOptions = {
method: "POST",
@@ -112,6 +114,8 @@ export const SendTestMail: React.FC<{ domain: Domain }> = ({ domain }) => {
const sendTestEmailFromDomainMutation =
api.domain.sendTestEmailFromDomain.useMutation();
const { data: session } = useSession();
const utils = api.useUtils();
function handleSendTestEmail() {
@@ -145,12 +149,14 @@ export const SendTestMail: React.FC<{ domain: Domain }> = ({ domain }) => {
<DialogTitle>Send test email</DialogTitle>
</DialogHeader>
<Code
codeBlocks={[
{ language: "js", code: jsCode },
{ language: "ruby", code: rubyCode },
{ language: "php", code: phpCode },
{ language: "python", code: pythonCode },
]}
codeBlocks={getSendTestEmailCode({
from: `hello@${domain.name}`,
to: session?.user?.email || "",
subject: "Unsend test email",
body: "hello,\\n\\nUnsend is the best open source sending platform",
bodyHtml:
"<p>hello,</p><p>Unsend is the best open source sending platform<p><p>check out <a href='https://unsend.dev'>unsend.dev</a>",
})}
codeClassName="max-w-[38rem] h-[20rem]"
/>
<div className="flex justify-end w-full">

View File

@@ -27,17 +27,37 @@ import * as tldts from "tldts";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@unsend/ui/src/select";
import { toast } from "@unsend/ui/src/toaster";
const domainSchema = z.object({
domain: z.string({ required_error: "Domain is required" }),
region: z.string({ required_error: "Region is required" }).min(1, {
message: "Region is required",
}),
domain: z.string({ required_error: "Domain is required" }).min(1, {
message: "Domain is required",
}),
});
export default function AddDomain() {
const [open, setOpen] = useState(false);
const regionQuery = api.domain.getAvailableRegions.useQuery();
const addDomainMutation = api.domain.createDomain.useMutation();
const domainForm = useForm<z.infer<typeof domainSchema>>({
resolver: zodResolver(domainSchema),
defaultValues: {
region: "",
domain: "",
},
});
const utils = api.useUtils();
@@ -56,6 +76,7 @@ export default function AddDomain() {
addDomainMutation.mutate(
{
name: values.domain,
region: values.region,
},
{
onSuccess: async (data) => {
@@ -63,6 +84,9 @@ export default function AddDomain() {
await router.push(`/domains/${data.id}`);
setOpen(false);
},
onError: async (error) => {
toast.error(error.message);
},
}
);
}
@@ -108,6 +132,41 @@ export default function AddDomain() {
</FormItem>
)}
/>
<FormField
control={domainForm.control}
name="region"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Region</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
disabled={regionQuery.isLoading}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select region" />
</SelectTrigger>
</FormControl>
<SelectContent>
{regionQuery.data?.map((region) => (
<SelectItem value={region} key={region}>
{region}
</SelectItem>
))}
</SelectContent>
</Select>
{formState.errors.region ? (
<FormMessage />
) : (
<FormDescription>
Select the region from where the email is sent{" "}
</FormDescription>
)}
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"

View File

@@ -95,9 +95,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
<div>
<p className="text-sm text-muted-foreground">Region</p>
<p className="text-sm flex items-center gap-2">
<span className="text-2xl">🇺🇸</span> {domain.region}
</p>
<p className="text-sm flex items-center gap-2">{domain.region}</p>
</div>
</div>
<div className="flex flex-col gap-6">

View File

@@ -1,14 +1,22 @@
"use client";
import { UAParser } from "ua-parser-js";
import { api } from "~/trpc/react";
import { Separator } from "@unsend/ui/src/separator";
import { EmailStatusBadge, EmailStatusIcon } from "./email-status-badge";
import { formatDate } from "date-fns";
import { EmailStatus } from "@prisma/client";
import { JsonValue } from "@prisma/client/runtime/library";
import { SesBounce, SesDeliveryDelay } from "~/types/aws-types";
import {
SesBounce,
SesClick,
SesComplaint,
SesDeliveryDelay,
SesOpen,
} from "~/types/aws-types";
import {
BOUNCE_ERROR_MESSAGES,
COMPLAINT_ERROR_MESSAGES,
DELIVERY_DELAY_ERRORS,
} from "~/lib/constants/ses-errors";
@@ -39,7 +47,7 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
<span className="w-[65px] text-muted-foreground ">Subject</span>
<span>{emailQuery.data?.subject}</span>
</div>
<div className=" dark:bg-slate-200 h-[300px] overflow-auto text-black rounded">
<div className=" dark:bg-slate-200 h-[250px] overflow-auto text-black rounded">
<div
className="px-4 py-4 overflow-auto"
dangerouslySetInnerHTML={{ __html: emailQuery.data?.html ?? "" }}
@@ -47,17 +55,20 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
</div>
</div>
<div className=" border rounded-lg w-full ">
<div className=" p-4 flex flex-col gap-8">
<div className=" p-4 flex flex-col gap-8 w-full">
<div className="font-medium">Events History</div>
<div className="flex items-stretch px-4">
<div className="flex items-stretch px-4 w-full">
<div className="border-r border-dashed" />
<div className="flex flex-col gap-12">
<div className="flex flex-col gap-12 w-full">
{emailQuery.data?.emailEvents.map((evt) => (
<div key={evt.status} className="flex gap-5 items-start">
<div
key={evt.status}
className="flex gap-5 items-start w-full"
>
<div className=" -ml-2.5">
<EmailStatusIcon status={evt.status} />
</div>
<div className="-mt-[0.125rem]">
<div className="-mt-[0.125rem] w-full">
<div className=" capitalize font-medium">
<EmailStatusBadge status={evt.status} />
</div>
@@ -104,26 +115,88 @@ const EmailStatusText = ({
_errorData.bounceType;
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 w-full">
<p>{getErrorMessage(_errorData)}</p>
<div className="flex gap-2 ">
<div className="w-1/2">
<p className="text-sm text-muted-foreground">Type </p>
<p>{_errorData.bounceType}</p>
<div className="rounded-xl p-4 bg-muted/20 flex flex-col gap-4">
<div className="flex gap-2 w-full">
<div className="w-1/2">
<p className="text-sm text-muted-foreground">Type</p>
<p>{_errorData.bounceType}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Sub Type</p>
<p>{_errorData.bounceSubType}</p>
</div>
</div>
<div>
<p className="text-sm text-muted-foreground">Sub Type </p>
<p>{_errorData.bounceSubType}</p>
<p className="text-sm text-muted-foreground">SMTP response</p>
<p>{_errorData.bouncedRecipients[0]?.diagnosticCode}</p>
</div>
</div>
<div>
<p className="text-sm text-muted-foreground">SMTP response</p>
<p>{_errorData.bouncedRecipients[0]?.diagnosticCode}</p>
</div>
</div>
);
} else if (status === "FAILED") {
const _errorData = data as unknown as { error: string };
return <div>{_errorData.error}</div>;
} else if (status === "OPENED") {
const _data = data as unknown as SesOpen;
const userAgent = getUserAgent(_data.userAgent);
return (
<div className="w-full rounded-xl p-4 bg-muted/20 mt-4">
<div className="flex w-full ">
{userAgent.os.name ? (
<div className="w-1/2">
<p className="text-sm text-muted-foreground">OS</p>
<p>{userAgent.os.name}</p>
</div>
) : null}
{userAgent.browser.name ? (
<div>
<p className="text-sm text-muted-foreground">Browser</p>
<p>{userAgent.browser.name}</p>
</div>
) : null}
</div>
</div>
);
} else if (status === "CLICKED") {
const _data = data as unknown as SesClick;
const userAgent = getUserAgent(_data.userAgent);
return (
<div className="w-full mt-4 flex flex-col gap-4 rounded-xl p-4 bg-muted/20">
<div className="flex w-full ">
{userAgent.os.name ? (
<div className="w-1/2">
<p className="text-sm text-muted-foreground">OS </p>
<p>{userAgent.os.name}</p>
</div>
) : null}
{userAgent.browser.name ? (
<div>
<p className="text-sm text-muted-foreground">Browser </p>
<p>{userAgent.browser.name}</p>
</div>
) : null}
</div>
<div className="w-full">
<p className="text-sm text-muted-foreground">URL</p>
<p>{_data.link}</p>
</div>
</div>
);
} else if (status === "COMPLAINED") {
const _errorData = data as unknown as SesComplaint;
return (
<div className="flex flex-col gap-4 w-full">
<p>{getComplaintMessage(_errorData.complaintFeedbackType)}</p>
</div>
);
}
return <div>{status}</div>;
return <div className="w-full">{status}</div>;
};
const getErrorMessage = (data: SesBounce) => {
@@ -148,3 +221,18 @@ const getErrorMessage = (data: SesBounce) => {
return BOUNCE_ERROR_MESSAGES.Undetermined;
}
};
const getComplaintMessage = (errorType: string) => {
return COMPLAINT_ERROR_MESSAGES[
errorType as keyof typeof COMPLAINT_ERROR_MESSAGES
];
};
const getUserAgent = (userAgent: string) => {
const parser = new UAParser(userAgent);
return {
browser: parser.getBrowser(),
os: parser.getOS(),
device: parser.getDevice(),
};
};

View File

@@ -190,6 +190,7 @@ const EmailIcon: React.FC<{ status: EmailStatus }> = ({ status }) => {
// </div>
);
case "BOUNCED":
case "FAILED":
return (
// <div className="border border-red-600/60 p-2 rounded-lg bg-red-500/10">
<MailX className="w-6 h-6 text-red-900" />

View File

@@ -12,6 +12,7 @@ export const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({
badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
break;
case "BOUNCED":
case "FAILED":
badgeColor = "bg-red-500/10 text-red-600 border-red-600/10";
break;
case "CLICKED":
@@ -51,6 +52,7 @@ export const EmailStatusIcon: React.FC<{ status: EmailStatus }> = ({
insideColor = "bg-emerald-500";
break;
case "BOUNCED":
case "FAILED":
outsideColor = "bg-red-500/30";
insideColor = "bg-red-500";
break;

View File

@@ -1,37 +1,6 @@
import Link from "next/link";
import {
BookOpenText,
BookUser,
CircleUser,
Code,
Globe,
Home,
LayoutDashboard,
LineChart,
LogOut,
Mail,
Menu,
Package,
Package2,
ShoppingCart,
Users,
Volume2,
} from "lucide-react";
import { Button } from "@unsend/ui/src/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@unsend/ui/src/dropdown-menu";
import { Sheet, SheetContent, SheetTrigger } from "@unsend/ui/src/sheet";
import { LogoutButton, NavButton } from "./nav-button";
import { DashboardProvider } from "~/providers/dashboard-provider";
import { NextAuthProvider } from "~/providers/next-auth";
import { DashboardLayout } from "./dasboard-layout";
export const dynamic = "force-static";
@@ -43,158 +12,7 @@ export default function AuthenticatedDashboardLayout({
return (
<NextAuthProvider>
<DashboardProvider>
<div className="flex min-h-screen w-full h-full">
<div className="hidden bg-muted/20 md:block md:w-[280px]">
<div className="flex h-full max-h-screen flex-col gap-2">
<div className="flex h-14 gap-4 items-center px-4 lg:h-[60px] lg:px-6">
<Link
href="/"
className="flex items-center gap-2 font-semibold"
>
<span className=" text-lg">Unsend</span>
</Link>
<span className="text-[10px] text-muted-foreground bg-muted p-0.5 px-2 rounded-full">
Early access
</span>
</div>
<div className="flex-1 h-full">
<nav className=" flex-1 h-full flex-col justify-between items-center px-2 text-sm font-medium lg:px-4">
<div>
<NavButton href="/dashboard">
<LayoutDashboard className="h-4 w-4" />
Dashboard
</NavButton>
<NavButton href="/emails">
<Mail className="h-4 w-4" />
Emails
</NavButton>
<NavButton href="/domains">
<Globe className="h-4 w-4" />
Domains
</NavButton>
<NavButton href="/contacts" comingSoon>
<BookUser className="h-4 w-4" />
Contacts
</NavButton>
<NavButton href="/contacts" comingSoon>
<Volume2 className="h-4 w-4" />
Marketing
</NavButton>
<NavButton href="/api-keys">
<Code className="h-4 w-4" />
Developer settings
</NavButton>
</div>
<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 hover:text-primary text-muted-foreground"
>
<BookOpenText className="h-4 w-4" />
<span className="">Docs</span>
</Link>
<LogoutButton />
</div>
</nav>
</div>
<div className="mt-auto p-4"></div>
</div>
</div>
<div className="flex flex-1 flex-col">
<header className="flex h-14 items-center gap-4 md:hidden bg-muted/20 px-4 lg:h-[60px] lg:px-6">
<Sheet>
<SheetTrigger asChild>
<Button
variant="outline"
size="icon"
className="shrink-0 md:hidden"
>
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="flex flex-col">
<nav className="grid gap-2 text-lg font-medium">
<Link
href="#"
className="flex items-center gap-2 text-lg font-semibold"
>
<Package2 className="h-6 w-6" />
<span className="sr-only">Acme Inc</span>
</Link>
<Link
href="#"
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
>
<Home className="h-5 w-5" />
Dashboard
</Link>
<Link
href="#"
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl bg-muted px-3 py-2 text-foreground hover:text-foreground"
>
<ShoppingCart className="h-5 w-5" />
Orders
</Link>
<Link
href="#"
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
>
<Package className="h-5 w-5" />
Products
</Link>
<Link
href="#"
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
>
<Users className="h-5 w-5" />
Customers
</Link>
<Link
href="#"
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
>
<LineChart className="h-5 w-5" />
Analytics
</Link>
</nav>
<div className="mt-auto"></div>
</SheetContent>
</Sheet>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="secondary"
size="icon"
className="rounded-full"
>
<CircleUser className="h-5 w-5" />
<span className="sr-only">Toggle user menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>Support</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</header>
<main className="flex-1 overflow-y-auto h-full">
<div className="flex flex-col gap-4 p-4 w-full lg:max-w-6xl mx-auto lg:gap-6 lg:p-6">
{children}
</div>
</main>
</div>
</div>
<DashboardLayout>{children}</DashboardLayout>
</DashboardProvider>
</NextAuthProvider>
);

View File

@@ -1,8 +1,5 @@
import { setupAws } from "~/server/aws/setup";
export const dynamic = "force-dynamic";
export async function GET() {
await setupAws();
return Response.json({ data: "Healthy" });
}

View File

@@ -1,11 +1,11 @@
import { db } from "~/server/db";
import { AppSettingsService } from "~/server/service/app-settings-service";
import { parseSesHook } from "~/server/service/ses-hook-parser";
import { SesSettingsService } from "~/server/service/ses-settings-service";
import { SnsNotificationMessage } from "~/types/aws-types";
import { APP_SETTINGS } from "~/utils/constants";
export async function GET(req: Request) {
console.log("GET", req);
export const dynamic = "force-dynamic";
export async function GET() {
return Response.json({ data: "Hello" });
}
@@ -14,10 +14,6 @@ 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);
@@ -72,26 +68,17 @@ async function handleSubscription(message: any) {
},
});
SesSettingsService.invalidateCache();
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;
const configuredTopicArn = await AppSettingsService.getSetting(
APP_SETTINGS.SNS_TOPIC_ARN
);
const configuredTopicArn = await SesSettingsService.getTopicArns();
if (TopicArn !== configuredTopicArn) {
if (!configuredTopicArn.includes(TopicArn)) {
return false;
}

View File

@@ -6,7 +6,6 @@ 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"],
@@ -24,12 +23,6 @@ export default async function RootLayout({
}: {
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

@@ -0,0 +1,184 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@unsend/ui/src/form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { api } from "~/trpc/react";
import { Input } from "@unsend/ui/src/input";
import { Button } from "@unsend/ui/src/button";
import Spinner from "@unsend/ui/src/spinner";
import { toast } from "@unsend/ui/src/toaster";
const FormSchema = z.object({
region: z.string(),
unsendUrl: z.string().url(),
sendRate: z.number(),
});
type SesSettingsProps = {
onSuccess?: () => void;
};
export const AddSesSettings: React.FC<SesSettingsProps> = ({ onSuccess }) => {
return (
<div className="flex items-center justify-center min-h-screen ">
<div className=" w-[400px] flex flex-col gap-8">
<div>
<h1 className="text-2xl font-semibold text-center">
Add SES Settings
</h1>
</div>
<AddSesSettingsForm onSuccess={onSuccess} />
</div>
</div>
);
};
export const AddSesSettingsForm: React.FC<SesSettingsProps> = ({
onSuccess,
}) => {
const addSesSettings = api.admin.addSesSettings.useMutation();
const utils = api.useUtils();
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
region: "",
unsendUrl: "",
sendRate: 1,
},
});
function onSubmit(data: z.infer<typeof FormSchema>) {
if (!data.unsendUrl.startsWith("https://")) {
form.setError("unsendUrl", {
message: "URL must start with https://",
});
return;
}
if (data.unsendUrl.includes("localhost")) {
form.setError("unsendUrl", {
message: "URL must be a valid url",
});
return;
}
addSesSettings.mutate(data, {
onSuccess: () => {
utils.admin.invalidate();
onSuccess?.();
},
onError: (e) => {
toast.error("Failed to create", {
description: e.message,
});
},
});
}
const onRegionInputOutOfFocus = async () => {
const region = form.getValues("region");
const quota = await utils.admin.getQuotaForRegion.fetch({ region });
form.setValue("sendRate", quota ?? 1);
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className=" flex flex-col gap-8 w-full"
>
<FormField
control={form.control}
name="region"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Region</FormLabel>
<FormControl>
<Input
placeholder="us-east-1"
className="w-full"
{...field}
onBlur={() => {
onRegionInputOutOfFocus();
field.onBlur();
}}
/>
</FormControl>
{formState.errors.region ? (
<FormMessage />
) : (
<FormDescription>The region of the SES account</FormDescription>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="unsendUrl"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Callback URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com"
className="w-full"
{...field}
/>
</FormControl>
{formState.errors.unsendUrl ? (
<FormMessage />
) : (
<FormDescription>
This url should be accessible from the internet. Will be
called from SES
</FormDescription>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="sendRate"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Send Rate</FormLabel>
<FormControl>
<Input placeholder="1" className="w-full" {...field} />
</FormControl>
{formState.errors.sendRate ? (
<FormMessage />
) : (
<FormDescription>
The number of emails to send per second.
</FormDescription>
)}
</FormItem>
)}
/>
<Button
type="submit"
disabled={addSesSettings.isPending}
className="w-[200px] mx-auto"
>
{addSesSettings.isPending ? (
<Spinner className="w-5 h-5" />
) : (
"Create"
)}
</Button>
</form>
</Form>
);
};

View File

@@ -1,3 +1,4 @@
import { EmailStatus } from "@prisma/client";
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
@@ -32,21 +33,19 @@ export const env = createEnv({
GITHUB_SECRET: z.string(),
AWS_ACCESS_KEY: z.string(),
AWS_SECRET_KEY: z.string(),
APP_URL: z.string().optional(),
SNS_TOPIC: 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),
.default(1),
FROM_EMAIL: z.string().optional(),
ADMIN_EMAIL: z.string().optional(),
DISCORD_WEBHOOK_URL: z.string().optional(),
REDIS_URL: z.string(),
},
/**
@@ -72,18 +71,16 @@ export const env = createEnv({
GITHUB_SECRET: process.env.GITHUB_SECRET,
AWS_ACCESS_KEY: process.env.AWS_ACCESS_KEY,
AWS_SECRET_KEY: process.env.AWS_SECRET_KEY,
APP_URL: process.env.APP_URL,
SNS_TOPIC: process.env.SNS_TOPIC,
UNSEND_API_KEY: process.env.UNSEND_API_KEY,
UNSEND_URL: process.env.UNSEND_URL,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
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,
DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL,
REDIS_URL: process.env.REDIS_URL,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially

View File

@@ -0,0 +1,19 @@
let initialized = false;
/**
* Add things here to be executed during server startup.
*
* more details here: https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
*/
export async function register() {
// eslint-disable-next-line turbo/no-undeclared-env-vars
if (process.env.NEXT_RUNTIME === "nodejs" && !initialized) {
console.log("Registering instrumentation");
const { EmailQueueService } = await import(
"~/server/service/email-queue-service"
);
await EmailQueueService.init();
initialized = true;
}
}

View File

@@ -0,0 +1,133 @@
import { CodeBlock } from "@unsend/ui/src/code";
export const getSendTestEmailCode = ({
from,
to,
subject,
body,
bodyHtml,
}: {
from: string;
to: string;
subject: string;
body: string;
bodyHtml: string;
}): Array<CodeBlock> => {
return [
{
language: "js",
title: "Node.js",
code: `import { Unsend } from "unsend";
const unsend = new Unsend({ apiKey: "us_12345" });
unsend.emails.send({
to: "${to}",
from: "${from}",
subject: "${subject}",
html: "${bodyHtml}",
text: "${body}",
});
`,
},
{
language: "python",
title: "Python",
code: `import requests
url = "https://app.unsend.dev/api/v1/emails"
payload = {
"to": "${to}",
"from": "${from}",
"subject": "${subject}",
"text": "${body}",
"html": "${bodyHtml}",
}
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer us_12345"
}
response = requests.request("POST", url, json=payload, headers=headers)
`,
},
{
language: "php",
title: "PHP",
code: `<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://app.unsend.dev/api/v1/emails",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => "{\\n \\"to\\": \\"${to}\\",\\n \\"from\\": \\"${from}\\",\\n \\"subject\\": \\"${subject}\\",\\n \\"replyTo\\": \\"${from}\\",\\n \\"text\\": \\"${body}\\",\\n \\"html\\": \\"${bodyHtml}\\"\\n}",
CURLOPT_HTTPHEADER => [
"Authorization: Bearer us_12345",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}
`,
},
{
language: "ruby",
title: "Ruby",
code: `require 'net/http'
require 'uri'
require 'json'
url = URI("https://app.unsend.dev/api/v1/emails")
payload = {
"to" => "${to}",
"from" => "${from}",
"subject" => "${subject}",
"text" => "${body}",
"html" => "${bodyHtml}"
}.to_json
headers = {
"Content-Type" => "application/json",
"Authorization" => "Bearer us_12345"
}
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url, headers)
request.body = payload
response = http.request(request)
puts response.body
`,
},
{
language: "curl",
title: "cURL",
code: `curl -X POST https://app.unsend.dev/api/v1/emails \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer us_12345" \\
-d '{"to": "${to}", "from": "${from}", "subject": "${subject}", "text": "${body}", "html": "${bodyHtml}"}'`,
},
];
};

View File

@@ -44,3 +44,14 @@ export const BOUNCE_ERROR_MESSAGES = {
"Unsend received an attachment rejected bounce. You may be able to successfully send to this recipient if you remove or change the attachment.",
},
};
export const COMPLAINT_ERROR_MESSAGES = {
abuse: "Indicates unsolicited email or some other kind of email abuse.",
"auth-failure": "Email authentication failure report.",
fraud: "Indicates some kind of fraud or phishing activity.",
"not-spam":
"Indicates that the entity providing the report does not consider the message to be spam. This may be used to correct a message that was incorrectly tagged or categorized as spam.",
other:
"Indicates any other feedback that does not fit into other registered types.",
virus: "Reports that a virus is found in the originating message.",
};

View File

@@ -1,7 +1,10 @@
"use client";
import { useSession } from "next-auth/react";
import { FullScreenLoading } from "~/components/FullScreenLoading";
import { AddSesSettings } from "~/components/settings/AddSesSettings";
import CreateTeam from "~/components/team/CreateTeam";
import { env } from "~/env";
import { api } from "~/trpc/react";
export const DashboardProvider = ({
@@ -9,12 +12,27 @@ export const DashboardProvider = ({
}: {
children: React.ReactNode;
}) => {
const { data: session } = useSession();
const { data: teams, status } = api.team.getTeams.useQuery();
const { data: settings, status: settingsStatus } =
api.admin.getSesSettings.useQuery(undefined, {
enabled: !env.NEXT_PUBLIC_IS_CLOUD || session?.user.isAdmin,
});
if (status === "pending") {
if (
status === "pending" ||
(settingsStatus === "pending" && !env.NEXT_PUBLIC_IS_CLOUD)
) {
return <FullScreenLoading />;
}
if (
settings?.length === 0 &&
(!env.NEXT_PUBLIC_IS_CLOUD || session?.user.isAdmin)
) {
return <AddSesSettings />;
}
if (!teams || teams.length === 0) {
return <CreateTeam />;
}

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,

View File

@@ -1,10 +1,12 @@
export type EmailContent = {
to: string;
to: string | string[];
from: string;
subject: string;
text?: string;
html?: string;
replyTo?: string;
replyTo?: string | string[];
cc?: string | string[];
bcc?: string | string[];
attachments?: Array<EmailAttachment>;
};

View File

@@ -1,9 +0,0 @@
import { env } from "~/env";
export const APP_SETTINGS = {
SNS_TOPIC_ARN: "SNS_TOPIC_ARN",
SES_CONFIGURATION_GENERAL: `SES_CONFIGURATION_GENERAL_${env.NODE_ENV}`,
SES_CONFIGURATION_CLICK_TRACKING: `SES_CONFIGURATION_CLICK_TRACKING_${env.NODE_ENV}`,
SES_CONFIGURATION_OPEN_TRACKING: `SES_CONFIGURATION_OPEN_TRACKING_${env.NODE_ENV}`,
SES_CONFIGURATION_FULL: `SES_CONFIGURATION_FULL_${env.NODE_ENV}`,
};

View File

@@ -1,18 +1,25 @@
import { APP_SETTINGS } from "./constants";
import { SesSettingsService } from "~/server/service/ses-settings-service";
export function getConfigurationSetName(
export async function getConfigurationSetName(
clickTracking: boolean,
openTracking: boolean
openTracking: boolean,
region: string
) {
const setting = await SesSettingsService.getSetting(region);
if (!setting) {
throw new Error(`No SES setting found for region: ${region}`);
}
if (clickTracking && openTracking) {
return APP_SETTINGS.SES_CONFIGURATION_FULL;
return setting.configFull;
}
if (clickTracking) {
return APP_SETTINGS.SES_CONFIGURATION_CLICK_TRACKING;
return setting.configClick;
}
if (openTracking) {
return APP_SETTINGS.SES_CONFIGURATION_OPEN_TRACKING;
return setting.configOpen;
}
return APP_SETTINGS.SES_CONFIGURATION_GENERAL;
return setting.configGeneral;
}

View File

@@ -4,6 +4,7 @@ ENV PATH="$PNPM_HOME:$PATH"
ENV SKIP_ENV_VALIDATION="true"
ENV DOCKER_OUTPUT 1
ENV NEXT_TELEMETRY_DISABLED 1
ENV NEXT_PUBLIC_IS_CLOUD="false"
RUN corepack enable
@@ -14,7 +15,7 @@ RUN apk update
WORKDIR /app
# Replace <your-major-version> with the major version installed in your repository. For example:
# RUN yarn global add turbo@^2
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json start.sh ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY ./apps/web ./apps/web
COPY ./packages ./packages
RUN pnpm add turbo@^1.12.5 -g
@@ -71,6 +72,6 @@ RUN ln -s /app/node_modules/prisma/build/index.js ./node_modules/.bin/prisma
# set this so it throws error where starting server
ENV SKIP_ENV_VALIDATION="false"
COPY start.sh ./
COPY ./docker/start.sh ./start.sh
CMD ["sh", "start.sh"]

26
docker/build.sh Normal file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
command -v docker >/dev/null 2>&1 || {
echo "Docker is not running. Please start Docker and try again."
exit 1
}
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
echo "Building docker image for monorepo at $MONOREPO_ROOT"
echo "App version: $APP_VERSION"
echo "Git SHA: $GIT_SHA"
docker build -f "$SCRIPT_DIR/Dockerfile" \
--progress=plain \
-t "unsend/unsend:latest" \
-t "unsend/unsend:$GIT_SHA" \
-t "unsend/unsend:$APP_VERSION" \
-t "ghcr.io/unsend/unsend:latest" \
-t "ghcr.io/unsend/unsend:$GIT_SHA" \
-t "ghcr.io/unsend/unsend:$APP_VERSION" \
"$MONOREPO_ROOT"

29
docker/dev/compose.yml Normal file
View File

@@ -0,0 +1,29 @@
name: unsend-dev
services:
postgres:
image: postgres:16
container_name: unsend-db-dev
restart: always
environment:
- POSTGRES_USER=unsend
- POSTGRES_PASSWORD=password
- POSTGRES_DB=unsend
volumes:
- database:/var/lib/postgresql/data
ports:
- "54320:5432"
redis:
image: redis:7
container_name: unsend-redis-dev
restart: always
ports:
- "6379:6379"
volumes:
- redis:/data
command: ["redis-server", "--maxmemory-policy", "noeviction"]
volumes:
database:
redis:

View File

@@ -3,7 +3,7 @@ name: unsend-prod
services:
postgres:
image: postgres:16
container_name: postgres
container_name: unsend-db-prod
restart: always
environment:
- POSTGRES_USER=${POSTGRES_USER:?err}
@@ -14,19 +14,23 @@ services:
interval: 10s
timeout: 5s
retries: 5
# ports:
# - "5432:5432"
volumes:
- database:/var/lib/postgresql/data
# You don't need to expose this port to the host since, docker compose creates an internal network
# through which both of these containers could talk to each other using their container_name as hostname
# But if you want to connect this to a querying tool to debug you can definitely uncomment this
redis:
image: redis:7
container_name: unsend-redis-prod
restart: always
# ports:
# - "5432:5432"
# - "6379:6379"
volumes:
- cache:/data
command: ["redis-server", "--maxmemory-policy", "noeviction"]
unsend:
build:
dockerfile: Dockerfile
image: unsend
image: unsend/unsend:latest
container_name: unsend
restart: always
ports:
@@ -41,16 +45,15 @@ services:
- AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:?err}
- GITHUB_ID=${GITHUB_ID:?err}
- GITHUB_SECRET=${GITHUB_SECRET:?err}
- APP_URL=${APP_URL:-${NEXTAUTH_URL}}
- SNS_TOPIC=${SNS_TOPIC:?err}
- REDIS_URL=${REDIS_URL:?err}
- NEXT_PUBLIC_IS_CLOUD=${NEXT_PUBLIC_IS_CLOUD:-false}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- SES_QUEUE_LIMIT=${SES_QUEUE_LIMIT:-1}
- API_RATE_LIMIT=${API_RATE_LIMIT:-1}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
volumes:
database:
cache:

View File

@@ -13,9 +13,14 @@
"db:push": "pnpm db db:push",
"db:migrate-dev": "pnpm db db:migrate-dev",
"db:migrate-deploy": "pnpm db db:migrate-deploy",
"db:migrate-reset": "pnpm db db:migrate-reset",
"db:studio": "pnpm db db:studio",
"db": "pnpm load-env -- pnpm --filter=web",
"load-env": "dotenv -e .env"
"load-env": "dotenv -e .env",
"d": "pnpm dx && pnpm dev",
"dx": "pnpm i && pnpm dx:up && pnpm db:migrate-dev",
"dx:up": "docker compose -f docker/dev/compose.yml up -d",
"dx:down": "docker compose -f docker/dev/compose.yml down"
},
"devDependencies": {
"@unsend/eslint-config": "workspace:*",

View File

@@ -9,7 +9,8 @@
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint . --max-warnings 0",
"build": "rm -rf dist && tsup index.ts --format esm,cjs --dts",
"publish-sdk": "pnpm run build && pnpm publish"
"publish-sdk": "pnpm run build && pnpm publish",
"openapi-typegen": "openapi-typescript ../../apps/docs/api-reference/openapi.json -o types/schema.d.ts"
},
"keywords": [],
"author": "",

View File

@@ -60,7 +60,10 @@ export interface paths {
"application/json": {
id: string;
teamId: number;
to: string;
to: string | string[];
replyTo?: string | string[];
cc?: string | string[];
bcc?: string | string[];
from: string;
subject: string;
html: string | null;
@@ -85,12 +88,13 @@ export interface paths {
requestBody: {
content: {
"application/json": {
/** Format: email */
to: string;
to: string | string[];
/** Format: email */
from: string;
subject: string;
replyTo?: string;
replyTo?: string | string[];
cc?: string | string[];
bcc?: string | string[];
text?: string;
html?: string;
attachments?: {

View File

@@ -11,13 +11,16 @@ import { ClipboardCopy, Check } from "lucide-react";
import { useState } from "react";
import { cn } from "../lib/utils";
type Language = "js" | "ruby" | "php" | "python" | "curl";
export type Language = "js" | "ruby" | "php" | "python" | "curl";
export type CodeBlock = {
language: Language;
title?: string;
code: string;
};
type CodeProps = {
codeBlocks: {
language: Language;
code: string;
}[];
codeBlocks: CodeBlock[];
codeClassName?: string;
};
@@ -57,7 +60,7 @@ export const Code: React.FC<CodeProps> = ({ codeBlocks, codeClassName }) => {
value={block.language}
className="data-[state=active]:bg-accent py-0.5 px-4 "
>
{block.language}
{block.title || block.language}
</TabsTrigger>
))}
</div>

175
pnpm-lock.yaml generated
View File

@@ -139,6 +139,9 @@ importers:
'@unsend/ui':
specifier: workspace:*
version: link:../../packages/ui
bullmq:
specifier: ^5.8.2
version: 5.8.2
date-fns:
specifier: ^3.6.0
version: 3.6.0
@@ -148,6 +151,9 @@ importers:
install:
specifier: ^0.13.0
version: 0.13.0
ioredis:
specifier: ^5.4.1
version: 5.4.1
lucide-react:
specifier: ^0.359.0
version: 0.359.0(react@18.2.0)
@@ -196,6 +202,9 @@ importers:
tldts:
specifier: ^6.1.16
version: 6.1.16
ua-parser-js:
specifier: ^1.0.38
version: 1.0.38
unsend:
specifier: workspace:*
version: link:../../packages/sdk
@@ -221,6 +230,9 @@ importers:
'@types/react-dom':
specifier: ^18.2.19
version: 18.2.22
'@types/ua-parser-js':
specifier: ^0.7.39
version: 0.7.39
'@typescript-eslint/eslint-plugin':
specifier: ^7.1.1
version: 7.2.0(@typescript-eslint/parser@7.2.0)(eslint@8.57.0)(typescript@5.4.2)
@@ -2086,6 +2098,10 @@ packages:
dev: true
optional: true
/@ioredis/commands@1.2.0:
resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==}
dev: false
/@isaacs/cliui@8.0.2:
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@@ -2374,6 +2390,54 @@ packages:
- debug
dev: true
/@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3:
resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
/@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3:
resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
/@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3:
resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3:
resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==}
cpu: [arm]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3:
resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3:
resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: false
optional: true
/@next/env@14.1.4:
resolution: {integrity: sha512-e7X7bbn3Z6DWnDi75UWn+REgAbLEqxI8Tq2pkFOFAMpWAWApz/YCUhtWMWn410h8Q2fYiYL7Yg5OlxMOCfFjJQ==}
dev: false
@@ -4320,6 +4384,10 @@ packages:
resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==}
dev: true
/@types/ua-parser-js@0.7.39:
resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==}
dev: true
/@types/unist@2.0.10:
resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==}
@@ -5146,6 +5214,20 @@ packages:
engines: {node: '>=6'}
dev: true
/bullmq@5.8.2:
resolution: {integrity: sha512-V64+Nz28FO9YEEUiDonG5KFhjihedML/OxuHpB0D5vV8aWcF1ui/5nmjDcCIyx4EXiUUDDypSUotjzcYu8gkeg==}
dependencies:
cron-parser: 4.9.0
ioredis: 5.4.1
msgpackr: 1.10.2
node-abort-controller: 3.1.1
semver: 7.6.0
tslib: 2.6.2
uuid: 9.0.1
transitivePeerDependencies:
- supports-color
dev: false
/bundle-require@4.1.0(esbuild@0.19.12):
resolution: {integrity: sha512-FeArRFM+ziGkRViKRnSTbHZc35dgmR9yNog05Kn0+ItI59pOAISGvnnIwW1WgFZQW59IxD9QpJnUPkdIPfZuXg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -5361,6 +5443,11 @@ packages:
engines: {node: '>=6'}
dev: false
/cluster-key-slot@1.1.2:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
dev: false
/color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
dependencies:
@@ -5618,7 +5705,6 @@ packages:
optional: true
dependencies:
ms: 2.1.2
dev: true
/decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
@@ -5695,6 +5781,11 @@ packages:
engines: {node: '>=0.4.0'}
dev: true
/denque@2.1.0:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
dev: false
/depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@@ -5722,7 +5813,6 @@ packages:
/detect-libc@2.0.3:
resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
engines: {node: '>=8'}
dev: true
/detect-newline@4.0.1:
resolution: {integrity: sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog==}
@@ -7690,6 +7780,23 @@ packages:
loose-envify: 1.4.0
dev: false
/ioredis@5.4.1:
resolution: {integrity: sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==}
engines: {node: '>=12.22.0'}
dependencies:
'@ioredis/commands': 1.2.0
cluster-key-slot: 1.1.2
debug: 4.3.4
denque: 2.1.0
lodash.defaults: 4.2.0
lodash.isarguments: 3.1.0
redis-errors: 1.2.0
redis-parser: 3.0.0
standard-as-callback: 2.1.0
transitivePeerDependencies:
- supports-color
dev: false
/ip-regex@4.3.0:
resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==}
engines: {node: '>=8'}
@@ -8234,6 +8341,14 @@ packages:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
dev: false
/lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
dev: false
/lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
dev: false
/lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
dev: true
@@ -9021,12 +9136,33 @@ packages:
/ms@2.1.2:
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
dev: true
/ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
dev: true
/msgpackr-extract@3.0.3:
resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==}
hasBin: true
requiresBuild: true
dependencies:
node-gyp-build-optional-packages: 5.2.2
optionalDependencies:
'@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3
'@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3
'@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3
'@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3
'@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3
'@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3
dev: false
optional: true
/msgpackr@1.10.2:
resolution: {integrity: sha512-L60rsPynBvNE+8BWipKKZ9jHcSGbtyJYIwjRq0VrIvQ08cRjntGXJYW/tmciZ2IHWIY8WEW32Qa2xbh5+SKBZA==}
optionalDependencies:
msgpackr-extract: 3.0.3
dev: false
/mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
dependencies:
@@ -9193,6 +9329,10 @@ packages:
'@types/nlcst': 1.0.4
dev: true
/node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
dev: false
/node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
@@ -9205,6 +9345,15 @@ packages:
whatwg-url: 5.0.0
dev: true
/node-gyp-build-optional-packages@5.2.2:
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
hasBin: true
requiresBuild: true
dependencies:
detect-libc: 2.0.3
dev: false
optional: true
/node-releases@2.0.14:
resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==}
dev: true
@@ -10253,6 +10402,18 @@ packages:
victory-vendor: 36.9.2
dev: false
/redis-errors@1.2.0:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
dev: false
/redis-parser@3.0.0:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'}
dependencies:
redis-errors: 1.2.0
dev: false
/reflect.getprototypeof@1.0.5:
resolution: {integrity: sha512-62wgfC8dJWrmxv44CA36pLDnP6KKl3Vhxb7PL+8+qrrFMMoJij4vgiMP8zV4O8+CBMXY1mHxI5fITGHXFHVmQQ==}
engines: {node: '>= 0.4'}
@@ -10900,6 +11061,10 @@ packages:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
dev: true
/standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
dev: false
/statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
@@ -11452,6 +11617,10 @@ packages:
hasBin: true
dev: true
/ua-parser-js@1.0.38:
resolution: {integrity: sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==}
dev: false
/unbox-primitive@1.0.2:
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
dependencies:

View File

@@ -23,8 +23,6 @@
"GITHUB_SECRET",
"AWS_SECRET_KEY",
"AWS_ACCESS_KEY",
"APP_URL",
"SNS_TOPIC",
"NEXTAUTH_SECRET",
"NODE_ENV",
"VERCEL_URL",