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.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
.env.selfhost
# Testing # Testing
coverage coverage

View File

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

View File

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

View File

@@ -13,7 +13,8 @@
"db:push": "prisma db push --skip-generate", "db:push": "prisma db push --skip-generate",
"db:migrate-dev": "prisma migrate dev", "db:migrate-dev": "prisma migrate dev",
"db:migrate-deploy": "prisma migrate deploy", "db:migrate-deploy": "prisma migrate deploy",
"db:studio": "prisma studio" "db:studio": "prisma studio",
"db:migrate-reset": "prisma migrate reset"
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^1.4.0", "@auth/prisma-adapter": "^1.4.0",
@@ -32,9 +33,11 @@
"@trpc/react-query": "next", "@trpc/react-query": "next",
"@trpc/server": "next", "@trpc/server": "next",
"@unsend/ui": "workspace:*", "@unsend/ui": "workspace:*",
"bullmq": "^5.8.2",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"hono": "^4.2.2", "hono": "^4.2.2",
"install": "^0.13.0", "install": "^0.13.0",
"ioredis": "^5.4.1",
"lucide-react": "^0.359.0", "lucide-react": "^0.359.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
@@ -51,6 +54,7 @@
"server-only": "^0.0.1", "server-only": "^0.0.1",
"superjson": "^2.2.1", "superjson": "^2.2.1",
"tldts": "^6.1.16", "tldts": "^6.1.16",
"ua-parser-js": "^1.0.38",
"unsend": "workspace:*", "unsend": "workspace:*",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
@@ -61,6 +65,7 @@
"@types/node": "^20.11.20", "@types/node": "^20.11.20",
"@types/react": "^18.2.57", "@types/react": "^18.2.57",
"@types/react-dom": "^18.2.19", "@types/react-dom": "^18.2.19",
"@types/ua-parser-js": "^0.7.39",
"@typescript-eslint/eslint-plugin": "^7.1.1", "@typescript-eslint/eslint-plugin": "^7.1.1",
"@typescript-eslint/parser": "^7.1.1", "@typescript-eslint/parser": "^7.1.1",
"@unsend/eslint-config": "workspace:*", "@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'); CREATE TYPE "ApiPermission" AS ENUM ('FULL', 'SENDING');
-- CreateEnum -- 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 -- CreateTable
CREATE TABLE "AppSetting" ( CREATE TABLE "AppSetting" (
@@ -18,6 +18,30 @@ CREATE TABLE "AppSetting" (
CONSTRAINT "AppSetting_pkey" PRIMARY KEY ("key") 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 -- CreateTable
CREATE TABLE "Account" ( CREATE TABLE "Account" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
@@ -61,6 +85,7 @@ CREATE TABLE "User" (
"email" TEXT, "email" TEXT,
"emailVerified" TIMESTAMP(3), "emailVerified" TIMESTAMP(3),
"image" TEXT, "image" TEXT,
"isBetaUser" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "User_pkey" PRIMARY KEY ("id") CONSTRAINT "User_pkey" PRIMARY KEY ("id")
); );
@@ -97,6 +122,7 @@ CREATE TABLE "Domain" (
"dmarcAdded" BOOLEAN NOT NULL DEFAULT false, "dmarcAdded" BOOLEAN NOT NULL DEFAULT false,
"errorMessage" TEXT, "errorMessage" TEXT,
"subdomain" TEXT, "subdomain" TEXT,
"isVerifying" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
@@ -122,28 +148,38 @@ CREATE TABLE "ApiKey" (
CREATE TABLE "Email" ( CREATE TABLE "Email" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"sesEmailId" TEXT, "sesEmailId" TEXT,
"to" TEXT NOT NULL,
"from" TEXT NOT NULL, "from" TEXT NOT NULL,
"to" TEXT[],
"replyTo" TEXT[],
"cc" TEXT[],
"bcc" TEXT[],
"subject" TEXT NOT NULL, "subject" TEXT NOT NULL,
"text" TEXT, "text" TEXT,
"html" TEXT, "html" TEXT,
"latestStatus" "EmailStatus" NOT NULL DEFAULT 'SENT', "latestStatus" "EmailStatus" NOT NULL DEFAULT 'QUEUED',
"teamId" INTEGER NOT NULL, "teamId" INTEGER NOT NULL,
"domainId" INTEGER, "domainId" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
"attachments" TEXT,
CONSTRAINT "Email_pkey" PRIMARY KEY ("id") CONSTRAINT "Email_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "EmailEvent" ( CREATE TABLE "EmailEvent" (
"id" TEXT NOT NULL,
"emailId" TEXT NOT NULL, "emailId" TEXT NOT NULL,
"status" "EmailStatus" NOT NULL, "status" "EmailStatus" NOT NULL,
"data" JSONB, "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 -- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); 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 -- CreateIndex
CREATE UNIQUE INDEX "Email_sesEmailId_key" ON "Email"("sesEmailId"); CREATE UNIQUE INDEX "Email_sesEmailId_key" ON "Email"("sesEmailId");
-- CreateIndex
CREATE UNIQUE INDEX "EmailEvent_emailId_status_key" ON "EmailEvent"("emailId", "status");
-- AddForeignKey -- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 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) configOpenSuccess Boolean @default(false)
configFull String? configFull String?
configFullSuccess Boolean @default(false) configFullSuccess Boolean @default(false)
sesEmailRateLimit Int @default(1)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@@ -162,23 +163,26 @@ model ApiKey {
enum EmailStatus { enum EmailStatus {
QUEUED QUEUED
SENT SENT
BOUNCED
DELIVERED
OPENED OPENED
CLICKED CLICKED
BOUNCED
COMPLAINED COMPLAINED
DELIVERED
REJECTED REJECTED
RENDERING_FAILURE RENDERING_FAILURE
DELIVERY_DELAYED DELIVERY_DELAYED
FAILED
} }
model Email { model Email {
id String @id @default(cuid()) id String @id @default(cuid())
sesEmailId String? @unique sesEmailId String? @unique
to String
from String from String
to String[]
replyTo String[]
cc String[]
bcc String[]
subject String subject String
replyTo String?
text String? text String?
html String? html String?
latestStatus EmailStatus @default(QUEUED) latestStatus EmailStatus @default(QUEUED)
@@ -192,11 +196,10 @@ model Email {
} }
model EmailEvent { model EmailEvent {
id String @id @default(cuid())
emailId String emailId String
status EmailStatus status EmailStatus
data Json? data Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
email Email @relation(fields: [emailId], references: [id], onDelete: Cascade) 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 SendTestMail from "./send-test-mail";
import { Button } from "@unsend/ui/src/button"; import { Button } from "@unsend/ui/src/button";
import Link from "next/link"; import Link from "next/link";
import { toast } from "@unsend/ui/src/toaster";
export default function DomainItemPage({ export default function DomainItemPage({
params, params,
@@ -245,7 +246,8 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
{ id: domain.id, clickTracking: !clickTracking }, { id: domain.id, clickTracking: !clickTracking },
{ {
onSuccess: () => { 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 }, { id: domain.id, openTracking: !openTracking },
{ {
onSuccess: () => { 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 { toast } from "@unsend/ui/src/toaster";
import { SendHorizonal } from "lucide-react"; import { SendHorizonal } from "lucide-react";
import { Code } from "@unsend/ui/src/code"; import { Code } from "@unsend/ui/src/code";
import { useSession } from "next-auth/react";
import { getSendTestEmailCode } from "~/lib/constants/example-codes";
const jsCode = `const requestOptions = { const jsCode = `const requestOptions = {
method: "POST", method: "POST",
@@ -112,6 +114,8 @@ export const SendTestMail: React.FC<{ domain: Domain }> = ({ domain }) => {
const sendTestEmailFromDomainMutation = const sendTestEmailFromDomainMutation =
api.domain.sendTestEmailFromDomain.useMutation(); api.domain.sendTestEmailFromDomain.useMutation();
const { data: session } = useSession();
const utils = api.useUtils(); const utils = api.useUtils();
function handleSendTestEmail() { function handleSendTestEmail() {
@@ -145,12 +149,14 @@ export const SendTestMail: React.FC<{ domain: Domain }> = ({ domain }) => {
<DialogTitle>Send test email</DialogTitle> <DialogTitle>Send test email</DialogTitle>
</DialogHeader> </DialogHeader>
<Code <Code
codeBlocks={[ codeBlocks={getSendTestEmailCode({
{ language: "js", code: jsCode }, from: `hello@${domain.name}`,
{ language: "ruby", code: rubyCode }, to: session?.user?.email || "",
{ language: "php", code: phpCode }, subject: "Unsend test email",
{ language: "python", code: pythonCode }, 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]" codeClassName="max-w-[38rem] h-[20rem]"
/> />
<div className="flex justify-end w-full"> <div className="flex justify-end w-full">

View File

@@ -27,17 +27,37 @@ import * as tldts from "tldts";
import { z } from "zod"; import { z } from "zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; 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({ 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() { export default function AddDomain() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const regionQuery = api.domain.getAvailableRegions.useQuery();
const addDomainMutation = api.domain.createDomain.useMutation(); const addDomainMutation = api.domain.createDomain.useMutation();
const domainForm = useForm<z.infer<typeof domainSchema>>({ const domainForm = useForm<z.infer<typeof domainSchema>>({
resolver: zodResolver(domainSchema), resolver: zodResolver(domainSchema),
defaultValues: {
region: "",
domain: "",
},
}); });
const utils = api.useUtils(); const utils = api.useUtils();
@@ -56,6 +76,7 @@ export default function AddDomain() {
addDomainMutation.mutate( addDomainMutation.mutate(
{ {
name: values.domain, name: values.domain,
region: values.region,
}, },
{ {
onSuccess: async (data) => { onSuccess: async (data) => {
@@ -63,6 +84,9 @@ export default function AddDomain() {
await router.push(`/domains/${data.id}`); await router.push(`/domains/${data.id}`);
setOpen(false); setOpen(false);
}, },
onError: async (error) => {
toast.error(error.message);
},
} }
); );
} }
@@ -108,6 +132,41 @@ export default function AddDomain() {
</FormItem> </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"> <div className="flex justify-end">
<Button <Button
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100" 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> <div>
<p className="text-sm text-muted-foreground">Region</p> <p className="text-sm text-muted-foreground">Region</p>
<p className="text-sm flex items-center gap-2"> <p className="text-sm flex items-center gap-2">{domain.region}</p>
<span className="text-2xl">🇺🇸</span> {domain.region}
</p>
</div> </div>
</div> </div>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">

View File

@@ -1,14 +1,22 @@
"use client"; "use client";
import { UAParser } from "ua-parser-js";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { Separator } from "@unsend/ui/src/separator"; import { Separator } from "@unsend/ui/src/separator";
import { EmailStatusBadge, EmailStatusIcon } from "./email-status-badge"; import { EmailStatusBadge, EmailStatusIcon } from "./email-status-badge";
import { formatDate } from "date-fns"; import { formatDate } from "date-fns";
import { EmailStatus } from "@prisma/client"; import { EmailStatus } from "@prisma/client";
import { JsonValue } from "@prisma/client/runtime/library"; 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 { import {
BOUNCE_ERROR_MESSAGES, BOUNCE_ERROR_MESSAGES,
COMPLAINT_ERROR_MESSAGES,
DELIVERY_DELAY_ERRORS, DELIVERY_DELAY_ERRORS,
} from "~/lib/constants/ses-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 className="w-[65px] text-muted-foreground ">Subject</span>
<span>{emailQuery.data?.subject}</span> <span>{emailQuery.data?.subject}</span>
</div> </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 <div
className="px-4 py-4 overflow-auto" className="px-4 py-4 overflow-auto"
dangerouslySetInnerHTML={{ __html: emailQuery.data?.html ?? "" }} dangerouslySetInnerHTML={{ __html: emailQuery.data?.html ?? "" }}
@@ -47,17 +55,20 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
</div> </div>
</div> </div>
<div className=" border rounded-lg w-full "> <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="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="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) => ( {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"> <div className=" -ml-2.5">
<EmailStatusIcon status={evt.status} /> <EmailStatusIcon status={evt.status} />
</div> </div>
<div className="-mt-[0.125rem]"> <div className="-mt-[0.125rem] w-full">
<div className=" capitalize font-medium"> <div className=" capitalize font-medium">
<EmailStatusBadge status={evt.status} /> <EmailStatusBadge status={evt.status} />
</div> </div>
@@ -104,26 +115,88 @@ const EmailStatusText = ({
_errorData.bounceType; _errorData.bounceType;
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<p>{getErrorMessage(_errorData)}</p> <p>{getErrorMessage(_errorData)}</p>
<div className="flex gap-2 "> <div className="rounded-xl p-4 bg-muted/20 flex flex-col gap-4">
<div className="w-1/2"> <div className="flex gap-2 w-full">
<p className="text-sm text-muted-foreground">Type </p> <div className="w-1/2">
<p>{_errorData.bounceType}</p> <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>
<div> <div>
<p className="text-sm text-muted-foreground">Sub Type </p> <p className="text-sm text-muted-foreground">SMTP response</p>
<p>{_errorData.bounceSubType}</p> <p>{_errorData.bouncedRecipients[0]?.diagnosticCode}</p>
</div> </div>
</div> </div>
<div> </div>
<p className="text-sm text-muted-foreground">SMTP response</p> );
<p>{_errorData.bouncedRecipients[0]?.diagnosticCode}</p> } else if (status === "FAILED") {
</div> 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> </div>
); );
} }
return <div>{status}</div>;
return <div className="w-full">{status}</div>;
}; };
const getErrorMessage = (data: SesBounce) => { const getErrorMessage = (data: SesBounce) => {
@@ -148,3 +221,18 @@ const getErrorMessage = (data: SesBounce) => {
return BOUNCE_ERROR_MESSAGES.Undetermined; 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> // </div>
); );
case "BOUNCED": case "BOUNCED":
case "FAILED":
return ( return (
// <div className="border border-red-600/60 p-2 rounded-lg bg-red-500/10"> // <div className="border border-red-600/60 p-2 rounded-lg bg-red-500/10">
<MailX className="w-6 h-6 text-red-900" /> <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"; badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
break; break;
case "BOUNCED": case "BOUNCED":
case "FAILED":
badgeColor = "bg-red-500/10 text-red-600 border-red-600/10"; badgeColor = "bg-red-500/10 text-red-600 border-red-600/10";
break; break;
case "CLICKED": case "CLICKED":
@@ -51,6 +52,7 @@ export const EmailStatusIcon: React.FC<{ status: EmailStatus }> = ({
insideColor = "bg-emerald-500"; insideColor = "bg-emerald-500";
break; break;
case "BOUNCED": case "BOUNCED":
case "FAILED":
outsideColor = "bg-red-500/30"; outsideColor = "bg-red-500/30";
insideColor = "bg-red-500"; insideColor = "bg-red-500";
break; 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 { DashboardProvider } from "~/providers/dashboard-provider";
import { NextAuthProvider } from "~/providers/next-auth"; import { NextAuthProvider } from "~/providers/next-auth";
import { DashboardLayout } from "./dasboard-layout";
export const dynamic = "force-static"; export const dynamic = "force-static";
@@ -43,158 +12,7 @@ export default function AuthenticatedDashboardLayout({
return ( return (
<NextAuthProvider> <NextAuthProvider>
<DashboardProvider> <DashboardProvider>
<div className="flex min-h-screen w-full h-full"> <DashboardLayout>{children}</DashboardLayout>
<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>
</DashboardProvider> </DashboardProvider>
</NextAuthProvider> </NextAuthProvider>
); );

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import { Toaster } from "@unsend/ui/src/toaster";
import { TRPCReactProvider } from "~/trpc/react"; import { TRPCReactProvider } from "~/trpc/react";
import { Metadata } from "next"; import { Metadata } from "next";
import { getBoss } from "~/server/service/job-service";
const inter = Inter({ const inter = Inter({
subsets: ["latin"], subsets: ["latin"],
@@ -24,12 +23,6 @@ export default async function RootLayout({
}: { }: {
children: React.ReactNode; 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 ( return (
<html lang="en"> <html lang="en">
<body className={`font-sans ${inter.variable}`}> <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 { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod"; import { z } from "zod";
@@ -32,21 +33,19 @@ export const env = createEnv({
GITHUB_SECRET: z.string(), GITHUB_SECRET: z.string(),
AWS_ACCESS_KEY: z.string(), AWS_ACCESS_KEY: z.string(),
AWS_SECRET_KEY: z.string(), AWS_SECRET_KEY: z.string(),
APP_URL: z.string().optional(),
SNS_TOPIC: z.string(),
UNSEND_API_KEY: z.string().optional(), UNSEND_API_KEY: z.string().optional(),
UNSEND_URL: z.string().optional(), UNSEND_URL: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(), GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: 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"), AWS_DEFAULT_REGION: z.string().default("us-east-1"),
API_RATE_LIMIT: z API_RATE_LIMIT: z
.string() .string()
.transform((str) => parseInt(str, 10)) .transform((str) => parseInt(str, 10))
.default(2), .default(1),
FROM_EMAIL: z.string().optional(), FROM_EMAIL: z.string().optional(),
ADMIN_EMAIL: z.string().optional(), ADMIN_EMAIL: z.string().optional(),
DISCORD_WEBHOOK_URL: 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, GITHUB_SECRET: process.env.GITHUB_SECRET,
AWS_ACCESS_KEY: process.env.AWS_ACCESS_KEY, AWS_ACCESS_KEY: process.env.AWS_ACCESS_KEY,
AWS_SECRET_KEY: process.env.AWS_SECRET_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_API_KEY: process.env.UNSEND_API_KEY,
UNSEND_URL: process.env.UNSEND_URL, UNSEND_URL: process.env.UNSEND_URL,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
SES_QUEUE_LIMIT: process.env.SES_QUEUE_LIMIT,
AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION, AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION,
API_RATE_LIMIT: process.env.API_RATE_LIMIT, API_RATE_LIMIT: process.env.API_RATE_LIMIT,
NEXT_PUBLIC_IS_CLOUD: process.env.NEXT_PUBLIC_IS_CLOUD, NEXT_PUBLIC_IS_CLOUD: process.env.NEXT_PUBLIC_IS_CLOUD,
ADMIN_EMAIL: process.env.ADMIN_EMAIL, ADMIN_EMAIL: process.env.ADMIN_EMAIL,
DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL, 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 * 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.", "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"; "use client";
import { useSession } from "next-auth/react";
import { FullScreenLoading } from "~/components/FullScreenLoading"; import { FullScreenLoading } from "~/components/FullScreenLoading";
import { AddSesSettings } from "~/components/settings/AddSesSettings";
import CreateTeam from "~/components/team/CreateTeam"; import CreateTeam from "~/components/team/CreateTeam";
import { env } from "~/env";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
export const DashboardProvider = ({ export const DashboardProvider = ({
@@ -9,12 +12,27 @@ export const DashboardProvider = ({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) => { }) => {
const { data: session } = useSession();
const { data: teams, status } = api.team.getTeams.useQuery(); 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 />; return <FullScreenLoading />;
} }
if (
settings?.length === 0 &&
(!env.NEXT_PUBLIC_IS_CLOUD || session?.user.isAdmin)
) {
return <AddSesSettings />;
}
if (!teams || teams.length === 0) { if (!teams || teams.length === 0) {
return <CreateTeam />; return <CreateTeam />;
} }

View File

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

View File

@@ -3,12 +3,24 @@ import { env } from "~/env";
import { createTRPCRouter, adminProcedure } from "~/server/api/trpc"; import { createTRPCRouter, adminProcedure } from "~/server/api/trpc";
import { SesSettingsService } from "~/server/service/ses-settings-service"; import { SesSettingsService } from "~/server/service/ses-settings-service";
import { getAccount } from "~/server/aws/ses";
export const adminRouter = createTRPCRouter({ export const adminRouter = createTRPCRouter({
getSesSettings: adminProcedure.query(async () => { getSesSettings: adminProcedure.query(async () => {
return SesSettingsService.getAllSettings(); 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 addSesSettings: adminProcedure
.input( .input(
z.object({ z.object({

View File

@@ -1,6 +1,10 @@
import { z } from "zod"; 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 { db } from "~/server/db";
import { import {
createDomain, createDomain,
@@ -9,12 +13,18 @@ import {
updateDomain, updateDomain,
} from "~/server/service/domain-service"; } from "~/server/service/domain-service";
import { sendEmail } from "~/server/service/email-service"; import { sendEmail } from "~/server/service/email-service";
import { SesSettingsService } from "~/server/service/ses-settings-service";
export const domainRouter = createTRPCRouter({ export const domainRouter = createTRPCRouter({
getAvailableRegions: protectedProcedure.query(async () => {
const settings = await SesSettingsService.getAllSettings();
return settings.map((setting) => setting.region);
}),
createDomain: teamProcedure createDomain: teamProcedure
.input(z.object({ name: z.string() })) .input(z.object({ name: z.string(), region: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
return createDomain(ctx.team.id, input.name); return createDomain(ctx.team.id, input.name, input.region);
}), }),
startVerification: teamProcedure startVerification: teamProcedure
@@ -93,9 +103,9 @@ export const domainRouter = createTRPCRouter({
teamId: team.id, teamId: team.id,
to: user.email, to: user.email,
from: `hello@${domain.name}`, from: `hello@${domain.name}`,
subject: "Test mail", subject: "Unsend test email",
text: "Hello this is a test mail", text: "hello,\n\nUnsend is the best open source sending platform\n\ncheck out https://unsend.dev",
html: "<p>Hello this is a test mail</p>", 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: { user: {
id: number; id: number;
isBetaUser: boolean; isBetaUser: boolean;
isAdmin: boolean;
// ...other properties // ...other properties
// role: UserRole; // role: UserRole;
} & DefaultSession["user"]; } & DefaultSession["user"];
@@ -34,6 +35,7 @@ declare module "next-auth" {
interface User { interface User {
id: number; id: number;
isBetaUser: boolean; isBetaUser: boolean;
isAdmin: boolean;
} }
} }
@@ -86,6 +88,7 @@ export const authOptions: NextAuthOptions = {
...session.user, ...session.user,
id: user.id, id: user.id,
isBetaUser: user.isBetaUser, isBetaUser: user.isBetaUser,
isAdmin: user.email === env.ADMIN_EMAIL,
}, },
}), }),
}, },

View File

@@ -8,14 +8,14 @@ import {
CreateConfigurationSetEventDestinationCommand, CreateConfigurationSetEventDestinationCommand,
CreateConfigurationSetCommand, CreateConfigurationSetCommand,
EventType, EventType,
GetAccountCommand,
} from "@aws-sdk/client-sesv2"; } from "@aws-sdk/client-sesv2";
import { generateKeyPairSync } from "crypto"; import { generateKeyPairSync } from "crypto";
import mime from "mime-types"; import mime from "mime-types";
import { env } from "~/env"; import { env } from "~/env";
import { EmailContent } from "~/types"; import { EmailContent } from "~/types";
import { APP_SETTINGS } from "~/utils/constants";
function getSesClient(region = "us-east-1") { function getSesClient(region: string) {
return new SESv2Client({ return new SESv2Client({
region: region, region: region,
credentials: { credentials: {
@@ -51,7 +51,7 @@ function generateKeyPair() {
return { privateKey: base64PrivateKey, publicKey: base64PublicKey }; 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 sesClient = getSesClient(region);
const { privateKey, publicKey } = generateKeyPair(); const { privateKey, publicKey } = generateKeyPair();
@@ -61,7 +61,6 @@ export async function addDomain(domain: string, region = "us-east-1") {
DomainSigningSelector: "unsend", DomainSigningSelector: "unsend",
DomainSigningPrivateKey: privateKey, DomainSigningPrivateKey: privateKey,
}, },
ConfigurationSetName: APP_SETTINGS.SES_CONFIGURATION_GENERAL,
}); });
const response = await sesClient.send(command); const response = await sesClient.send(command);
@@ -84,7 +83,7 @@ export async function addDomain(domain: string, region = "us-east-1") {
return publicKey; 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 sesClient = getSesClient(region);
const command = new DeleteEmailIdentityCommand({ const command = new DeleteEmailIdentityCommand({
EmailIdentity: domain, EmailIdentity: domain,
@@ -93,7 +92,7 @@ export async function deleteDomain(domain: string, region = "us-east-1") {
return response.$metadata.httpStatusCode === 200; 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 sesClient = getSesClient(region);
const command = new GetEmailIdentityCommand({ const command = new GetEmailIdentityCommand({
EmailIdentity: domain, EmailIdentity: domain,
@@ -106,21 +105,29 @@ export async function sendEmailThroughSes({
to, to,
from, from,
subject, subject,
cc,
bcc,
text, text,
html, html,
replyTo, replyTo,
region = "us-east-1", region,
configurationSetName, configurationSetName,
}: EmailContent & { }: Partial<EmailContent> & {
region?: string; region: string;
configurationSetName: string; configurationSetName: string;
cc?: string[];
bcc?: string[];
replyTo?: string[];
to?: string[];
}) { }) {
const sesClient = getSesClient(region); const sesClient = getSesClient(region);
const command = new SendEmailCommand({ const command = new SendEmailCommand({
FromEmailAddress: from, FromEmailAddress: from,
ReplyToAddresses: replyTo ? [replyTo] : undefined, ReplyToAddresses: replyTo ? replyTo : undefined,
Destination: { Destination: {
ToAddresses: [to], ToAddresses: to,
CcAddresses: cc,
BccAddresses: bcc,
}, },
Content: { Content: {
// EmailContent // EmailContent
@@ -153,7 +160,7 @@ export async function sendEmailThroughSes({
return response.MessageId; return response.MessageId;
} catch (error) { } catch (error) {
console.error("Failed to send email", 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, from,
subject, subject,
replyTo, replyTo,
cc,
bcc,
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
text, text,
html, html,
attachments, attachments,
region = "us-east-1", region,
configurationSetName, configurationSetName,
}: EmailContent & { }: Partial<EmailContent> & {
region?: string; region: string;
configurationSetName: string; configurationSetName: string;
attachments: { filename: string; content: string }[]; attachments: { filename: string; content: string }[];
cc?: string[];
bcc?: string[];
replyTo?: string[];
to?: string[];
}) { }) {
const sesClient = getSesClient(region); const sesClient = getSesClient(region);
const boundary = "NextPart"; const boundary = "NextPart";
let rawEmail = `From: ${from}\n`; 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 += `Reply-To: ${replyTo}\n`;
rawEmail += `Subject: ${subject}\n`; rawEmail += `Subject: ${subject}\n`;
rawEmail += `MIME-Version: 1.0\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( export async function addWebhookConfiguration(
configName: string, configName: string,
topicArn: string, topicArn: string,
eventTypes: EventType[], eventTypes: EventType[],
region = "us-east-1" region: string
) { ) {
const sesClient = getSesClient(region); 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"; } from "@aws-sdk/client-sns";
import { env } from "~/env"; import { env } from "~/env";
function getSnsClient(region = "us-east-1") { function getSnsClient(region: string) {
return new SNSClient({ return new SNSClient({
region: region, region: region,
credentials: { credentials: {
@@ -15,8 +15,8 @@ function getSnsClient(region = "us-east-1") {
}); });
} }
export async function createTopic(topic: string) { export async function createTopic(topic: string, region: string) {
const client = getSnsClient(); const client = getSnsClient(region);
const command = new CreateTopicCommand({ const command = new CreateTopicCommand({
Name: topic, Name: topic,
}); });
@@ -25,13 +25,17 @@ export async function createTopic(topic: string) {
return data.TopicArn; 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({ const subscribeCommand = new SubscribeCommand({
Protocol: "https", Protocol: "https",
TopicArn: topicArn, TopicArn: topicArn,
Endpoint: endpointUrl, Endpoint: endpointUrl,
}); });
const client = getSnsClient(); const client = getSnsClient(region);
const data = await client.send(subscribeCommand); const data = await client.send(subscribeCommand);
console.log(data.SubscriptionArn); console.log(data.SubscriptionArn);

View File

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

View File

@@ -12,10 +12,12 @@ const route = createRoute({
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: z.object({
to: z.string().email(), to: z.string().or(z.array(z.string())),
from: z.string().email(), from: z.string(),
subject: 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(), text: z.string().optional(),
html: z.string().optional(), html: z.string().optional(),
attachments: z attachments: z

View File

@@ -15,7 +15,7 @@ export function getApp() {
version: "1.0.0", version: "1.0.0",
title: "Unsend API", title: "Unsend API",
}, },
servers: [{ url: `${env.APP_URL}/api` }], servers: [{ url: `${env.NEXTAUTH_URL}/api` }],
})); }));
app.openAPIRegistry.registerComponent("securitySchemes", "Bearer", { 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 tldts from "tldts";
import * as ses from "~/server/aws/ses"; import * as ses from "~/server/aws/ses";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { SesSettingsService } from "./ses-settings-service";
const dnsResolveTxt = util.promisify(dns.resolveTxt); 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); const domainStr = tldts.getDomain(name);
if (!domainStr) { if (!domainStr) {
throw new Error("Invalid domain"); 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 subdomain = tldts.getSubdomain(name);
const publicKey = await ses.addDomain(name); const publicKey = await ses.addDomain(name, region);
const domain = await db.domain.create({ const domain = await db.domain.create({
data: { data: {
@@ -22,6 +33,7 @@ export async function createDomain(teamId: number, name: string) {
publicKey, publicKey,
teamId, teamId,
subdomain, 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 { EmailContent } from "~/types";
import { db } from "../db"; import { db } from "../db";
import { UnsendApiError } from "~/server/public-api/api-error"; import { UnsendApiError } from "~/server/public-api/api-error";
import { queueEmail } from "./job-service"; import { EmailQueueService } from "./email-queue-service";
export async function sendEmail( export async function sendEmail(
emailContent: EmailContent & { teamId: number } emailContent: EmailContent & { teamId: number }
) { ) {
const { to, from, subject, text, html, teamId, attachments, replyTo } = const {
emailContent; to,
from,
subject,
text,
html,
teamId,
attachments,
replyTo,
cc,
bcc,
} = emailContent;
const fromDomain = from.split("@")[1]; const fromDomain = from.split("@")[1];
@@ -32,10 +42,16 @@ export async function sendEmail(
const email = await db.email.create({ const email = await db.email.create({
data: { data: {
to, to: Array.isArray(to) ? to : [to],
from, from,
subject, 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, text,
html, html,
teamId, 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; 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 { SesEvent, SesEventDataKey } from "~/types/aws-types";
import { db } from "../db"; import { db } from "../db";
const STATUS_LIST = Object.values(EmailStatus);
export async function parseSesHook(data: SesEvent) { export async function parseSesHook(data: SesEvent) {
const mailStatus = getEmailStatus(data); const mailStatus = getEmailStatus(data);
@@ -30,21 +32,12 @@ export async function parseSesHook(data: SesEvent) {
id: email.id, id: email.id,
}, },
data: { data: {
latestStatus: mailStatus, latestStatus: getLatestStatus(email.latestStatus, mailStatus),
}, },
}); });
await db.emailEvent.upsert({ await db.emailEvent.create({
where: { data: {
emailId_status: {
emailId: email.id,
status: mailStatus,
},
},
update: {
data: mailData as any,
},
create: {
emailId: email.id, emailId: email.id,
status: mailStatus, status: mailStatus,
data: mailData as any, data: mailData as any,
@@ -89,3 +82,12 @@ function getEmailData(data: SesEvent) {
return data[eventType.toLowerCase() as SesEventDataKey]; 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 sns from "~/server/aws/sns";
import * as ses from "~/server/aws/ses"; import * as ses from "~/server/aws/ses";
import { EventType } from "@aws-sdk/client-sesv2"; 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[] = [ const GENERAL_EVENTS: EventType[] = [
"BOUNCE", "BOUNCE",
@@ -21,15 +22,26 @@ const GENERAL_EVENTS: EventType[] = [
export class SesSettingsService { export class SesSettingsService {
private static cache: Record<string, SesSetting> = {}; 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]) { if (this.cache[region]) {
return this.cache[region] as SesSetting; return this.cache[region] as SesSetting;
} }
return null; 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); return Object.values(this.cache);
} }
@@ -46,15 +58,20 @@ export class SesSettingsService {
region: string; region: string;
unsendUrl: string; unsendUrl: string;
}) { }) {
await this.checkInitialized();
if (this.cache[region]) { if (this.cache[region]) {
throw new Error(`SesSetting for region ${region} already exists`); 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) { if (!unsendUrlValidation.isValid) {
throw new Error( 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({ const setting = await db.sesSetting.create({
data: { data: {
region, region,
callbackUrl: `${unsendUrl}/api/ses_callback`, callbackUrl: `${parsedUrl}/api/ses_callback`,
topic: `${idPrefix}-${region}-unsend`, topic: `${idPrefix}-${region}-unsend`,
idPrefix, idPrefix,
}, },
}); });
await createSettingInAws(setting); 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(); const settings = await db.sesSetting.findMany();
settings.forEach((setting) => { settings.forEach((setting) => {
this.cache[setting.region] = 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) { 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 * Creates a new topic in AWS and subscribes the callback URL to it
*/ */
async function registerTopicInAws(setting: SesSetting) { async function registerTopicInAws(setting: SesSetting) {
const topicArn = await sns.createTopic(setting.topic); const topicArn = await sns.createTopic(setting.topic, setting.region);
if (!topicArn) { if (!topicArn) {
throw new Error("Failed to create SNS topic"); throw new Error("Failed to create SNS topic");
} }
await sns.subscribeEndpoint( const _setting = await db.sesSetting.update({
topicArn,
`${setting.callbackUrl}/api/ses_callback`
);
return await db.sesSetting.update({
where: { where: {
id: setting.id, id: setting.id,
}, },
@@ -114,6 +133,17 @@ async function registerTopicInAws(setting: SesSetting) {
topicArn, 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( const generalStatus = await ses.addWebhookConfiguration(
configGeneral, configGeneral,
setting.topicArn, setting.topicArn,
GENERAL_EVENTS GENERAL_EVENTS,
setting.region
); );
const configClick = `${setting.idPrefix}-${setting.region}-unsend-click`; const configClick = `${setting.idPrefix}-${setting.region}-unsend-click`;
const clickStatus = await ses.addWebhookConfiguration( const clickStatus = await ses.addWebhookConfiguration(
configClick, configClick,
setting.topicArn, setting.topicArn,
[...GENERAL_EVENTS, "CLICK"] [...GENERAL_EVENTS, "CLICK"],
setting.region
); );
const configOpen = `${setting.idPrefix}-${setting.region}-unsend-open`; const configOpen = `${setting.idPrefix}-${setting.region}-unsend-open`;
const openStatus = await ses.addWebhookConfiguration( const openStatus = await ses.addWebhookConfiguration(
configOpen, configOpen,
setting.topicArn, setting.topicArn,
[...GENERAL_EVENTS, "OPEN"] [...GENERAL_EVENTS, "OPEN"],
setting.region
); );
const configFull = `${setting.idPrefix}-${setting.region}-unsend-full`; const configFull = `${setting.idPrefix}-${setting.region}-unsend-full`;
const fullStatus = await ses.addWebhookConfiguration( const fullStatus = await ses.addWebhookConfiguration(
configFull, configFull,
setting.topicArn, setting.topicArn,
[...GENERAL_EVENTS, "CLICK", "OPEN"] [...GENERAL_EVENTS, "CLICK", "OPEN"],
setting.region
); );
return await db.sesSetting.update({ return await db.sesSetting.update({
@@ -175,10 +209,10 @@ async function registerConfigurationSet(setting: SesSetting) {
} }
async function isValidUnsendUrl(url: string) { async function isValidUnsendUrl(url: string) {
console.log("Checking if URL is valid", url);
try { try {
const response = await fetch(`${url}/api/ses_callback`, { const response = await fetch(`${url}/api/ses_callback`, {
method: "POST", method: "GET",
body: JSON.stringify({ fromUnsend: true }),
}); });
return { return {
isValid: response.status === 200, isValid: response.status === 200,
@@ -186,6 +220,7 @@ async function isValidUnsendUrl(url: string) {
error: response.statusText, error: response.statusText,
}; };
} catch (e) { } catch (e) {
console.log("Error checking if URL is valid", e);
return { return {
isValid: false, isValid: false,
code: 500, code: 500,

View File

@@ -1,10 +1,12 @@
export type EmailContent = { export type EmailContent = {
to: string; to: string | string[];
from: string; from: string;
subject: string; subject: string;
text?: string; text?: string;
html?: string; html?: string;
replyTo?: string; replyTo?: string | string[];
cc?: string | string[];
bcc?: string | string[];
attachments?: Array<EmailAttachment>; 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, 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) { if (clickTracking && openTracking) {
return APP_SETTINGS.SES_CONFIGURATION_FULL; return setting.configFull;
} }
if (clickTracking) { if (clickTracking) {
return APP_SETTINGS.SES_CONFIGURATION_CLICK_TRACKING; return setting.configClick;
} }
if (openTracking) { 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 SKIP_ENV_VALIDATION="true"
ENV DOCKER_OUTPUT 1 ENV DOCKER_OUTPUT 1
ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_TELEMETRY_DISABLED 1
ENV NEXT_PUBLIC_IS_CLOUD="false"
RUN corepack enable RUN corepack enable
@@ -14,7 +15,7 @@ RUN apk update
WORKDIR /app WORKDIR /app
# Replace <your-major-version> with the major version installed in your repository. For example: # Replace <your-major-version> with the major version installed in your repository. For example:
# RUN yarn global add turbo@^2 # 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 ./apps/web ./apps/web
COPY ./packages ./packages COPY ./packages ./packages
RUN pnpm add turbo@^1.12.5 -g 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 # set this so it throws error where starting server
ENV SKIP_ENV_VALIDATION="false" ENV SKIP_ENV_VALIDATION="false"
COPY start.sh ./ COPY ./docker/start.sh ./start.sh
CMD ["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: services:
postgres: postgres:
image: postgres:16 image: postgres:16
container_name: postgres container_name: unsend-db-prod
restart: always restart: always
environment: environment:
- POSTGRES_USER=${POSTGRES_USER:?err} - POSTGRES_USER=${POSTGRES_USER:?err}
@@ -14,19 +14,23 @@ services:
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
# ports:
# - "5432:5432"
volumes: volumes:
- database:/var/lib/postgresql/data - database:/var/lib/postgresql/data
# You don't need to expose this port to the host since, docker compose creates an internal network redis:
# through which both of these containers could talk to each other using their container_name as hostname image: redis:7
# But if you want to connect this to a querying tool to debug you can definitely uncomment this container_name: unsend-redis-prod
restart: always
# ports: # ports:
# - "5432:5432" # - "6379:6379"
volumes:
- cache:/data
command: ["redis-server", "--maxmemory-policy", "noeviction"]
unsend: unsend:
build: image: unsend/unsend:latest
dockerfile: Dockerfile
image: unsend
container_name: unsend container_name: unsend
restart: always restart: always
ports: ports:
@@ -41,16 +45,15 @@ services:
- AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:?err} - AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:?err}
- GITHUB_ID=${GITHUB_ID:?err} - GITHUB_ID=${GITHUB_ID:?err}
- GITHUB_SECRET=${GITHUB_SECRET:?err} - GITHUB_SECRET=${GITHUB_SECRET:?err}
- APP_URL=${APP_URL:-${NEXTAUTH_URL}} - REDIS_URL=${REDIS_URL:?err}
- SNS_TOPIC=${SNS_TOPIC:?err}
- NEXT_PUBLIC_IS_CLOUD=${NEXT_PUBLIC_IS_CLOUD:-false} - 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} - API_RATE_LIMIT=${API_RATE_LIMIT:-1}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
redis:
condition: service_started
volumes: volumes:
database: database:
cache:

View File

@@ -13,9 +13,14 @@
"db:push": "pnpm db db:push", "db:push": "pnpm db db:push",
"db:migrate-dev": "pnpm db db:migrate-dev", "db:migrate-dev": "pnpm db db:migrate-dev",
"db:migrate-deploy": "pnpm db db:migrate-deploy", "db:migrate-deploy": "pnpm db db:migrate-deploy",
"db:migrate-reset": "pnpm db db:migrate-reset",
"db:studio": "pnpm db db:studio", "db:studio": "pnpm db db:studio",
"db": "pnpm load-env -- pnpm --filter=web", "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": { "devDependencies": {
"@unsend/eslint-config": "workspace:*", "@unsend/eslint-config": "workspace:*",

View File

@@ -9,7 +9,8 @@
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint . --max-warnings 0", "lint": "eslint . --max-warnings 0",
"build": "rm -rf dist && tsup index.ts --format esm,cjs --dts", "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": [], "keywords": [],
"author": "", "author": "",

View File

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

View File

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

175
pnpm-lock.yaml generated
View File

@@ -139,6 +139,9 @@ importers:
'@unsend/ui': '@unsend/ui':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/ui version: link:../../packages/ui
bullmq:
specifier: ^5.8.2
version: 5.8.2
date-fns: date-fns:
specifier: ^3.6.0 specifier: ^3.6.0
version: 3.6.0 version: 3.6.0
@@ -148,6 +151,9 @@ importers:
install: install:
specifier: ^0.13.0 specifier: ^0.13.0
version: 0.13.0 version: 0.13.0
ioredis:
specifier: ^5.4.1
version: 5.4.1
lucide-react: lucide-react:
specifier: ^0.359.0 specifier: ^0.359.0
version: 0.359.0(react@18.2.0) version: 0.359.0(react@18.2.0)
@@ -196,6 +202,9 @@ importers:
tldts: tldts:
specifier: ^6.1.16 specifier: ^6.1.16
version: 6.1.16 version: 6.1.16
ua-parser-js:
specifier: ^1.0.38
version: 1.0.38
unsend: unsend:
specifier: workspace:* specifier: workspace:*
version: link:../../packages/sdk version: link:../../packages/sdk
@@ -221,6 +230,9 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: ^18.2.19 specifier: ^18.2.19
version: 18.2.22 version: 18.2.22
'@types/ua-parser-js':
specifier: ^0.7.39
version: 0.7.39
'@typescript-eslint/eslint-plugin': '@typescript-eslint/eslint-plugin':
specifier: ^7.1.1 specifier: ^7.1.1
version: 7.2.0(@typescript-eslint/parser@7.2.0)(eslint@8.57.0)(typescript@5.4.2) 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 dev: true
optional: true optional: true
/@ioredis/commands@1.2.0:
resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==}
dev: false
/@isaacs/cliui@8.0.2: /@isaacs/cliui@8.0.2:
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -2374,6 +2390,54 @@ packages:
- debug - debug
dev: true 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: /@next/env@14.1.4:
resolution: {integrity: sha512-e7X7bbn3Z6DWnDi75UWn+REgAbLEqxI8Tq2pkFOFAMpWAWApz/YCUhtWMWn410h8Q2fYiYL7Yg5OlxMOCfFjJQ==} resolution: {integrity: sha512-e7X7bbn3Z6DWnDi75UWn+REgAbLEqxI8Tq2pkFOFAMpWAWApz/YCUhtWMWn410h8Q2fYiYL7Yg5OlxMOCfFjJQ==}
dev: false dev: false
@@ -4320,6 +4384,10 @@ packages:
resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==}
dev: true dev: true
/@types/ua-parser-js@0.7.39:
resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==}
dev: true
/@types/unist@2.0.10: /@types/unist@2.0.10:
resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==} resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==}
@@ -5146,6 +5214,20 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
dev: true 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): /bundle-require@4.1.0(esbuild@0.19.12):
resolution: {integrity: sha512-FeArRFM+ziGkRViKRnSTbHZc35dgmR9yNog05Kn0+ItI59pOAISGvnnIwW1WgFZQW59IxD9QpJnUPkdIPfZuXg==} resolution: {integrity: sha512-FeArRFM+ziGkRViKRnSTbHZc35dgmR9yNog05Kn0+ItI59pOAISGvnnIwW1WgFZQW59IxD9QpJnUPkdIPfZuXg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -5361,6 +5443,11 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
dev: false 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: /color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
dependencies: dependencies:
@@ -5618,7 +5705,6 @@ packages:
optional: true optional: true
dependencies: dependencies:
ms: 2.1.2 ms: 2.1.2
dev: true
/decimal.js-light@2.5.1: /decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
@@ -5695,6 +5781,11 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
dev: true dev: true
/denque@2.1.0:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
dev: false
/depd@2.0.0: /depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -5722,7 +5813,6 @@ packages:
/detect-libc@2.0.3: /detect-libc@2.0.3:
resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
engines: {node: '>=8'} engines: {node: '>=8'}
dev: true
/detect-newline@4.0.1: /detect-newline@4.0.1:
resolution: {integrity: sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog==} resolution: {integrity: sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog==}
@@ -7690,6 +7780,23 @@ packages:
loose-envify: 1.4.0 loose-envify: 1.4.0
dev: false 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: /ip-regex@4.3.0:
resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==} resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -8234,6 +8341,14 @@ packages:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
dev: false 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: /lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
dev: true dev: true
@@ -9021,12 +9136,33 @@ packages:
/ms@2.1.2: /ms@2.1.2:
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
dev: true
/ms@2.1.3: /ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
dev: true 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: /mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
dependencies: dependencies:
@@ -9193,6 +9329,10 @@ packages:
'@types/nlcst': 1.0.4 '@types/nlcst': 1.0.4
dev: true dev: true
/node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
dev: false
/node-fetch@2.7.0: /node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0} engines: {node: 4.x || >=6.0.0}
@@ -9205,6 +9345,15 @@ packages:
whatwg-url: 5.0.0 whatwg-url: 5.0.0
dev: true 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: /node-releases@2.0.14:
resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==}
dev: true dev: true
@@ -10253,6 +10402,18 @@ packages:
victory-vendor: 36.9.2 victory-vendor: 36.9.2
dev: false 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: /reflect.getprototypeof@1.0.5:
resolution: {integrity: sha512-62wgfC8dJWrmxv44CA36pLDnP6KKl3Vhxb7PL+8+qrrFMMoJij4vgiMP8zV4O8+CBMXY1mHxI5fITGHXFHVmQQ==} resolution: {integrity: sha512-62wgfC8dJWrmxv44CA36pLDnP6KKl3Vhxb7PL+8+qrrFMMoJij4vgiMP8zV4O8+CBMXY1mHxI5fITGHXFHVmQQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -10900,6 +11061,10 @@ packages:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
dev: true dev: true
/standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
dev: false
/statuses@2.0.1: /statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -11452,6 +11617,10 @@ packages:
hasBin: true hasBin: true
dev: true dev: true
/ua-parser-js@1.0.38:
resolution: {integrity: sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==}
dev: false
/unbox-primitive@1.0.2: /unbox-primitive@1.0.2:
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
dependencies: dependencies:

View File

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