From f77a8829bebc535ebd0828f67c68779cac15fa87 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Mon, 24 Jun 2024 08:21:37 +1000 Subject: [PATCH] Improve Self host setup (#30) * Add self host setup * Improve blunders * Move to bull mq * More changes * Add example code for sending test emails --- .dockerignore | 34 ++++ .env.selfhost.example | 30 +++ .gitignore | 1 + apps/docs/api-reference/openapi.json | 114 ++++++++++- apps/web/next.config.js | 5 + apps/web/package.json | 7 +- .../migration.sql | 2 - .../migration.sql | 5 - .../migration.sql | 2 - .../20240515211820_add_reply_to/migration.sql | 2 - .../migration.sql | 2 - .../migration.sql | 25 --- .../migration.sql | 47 ++++- apps/web/prisma/schema.prisma | 15 +- .../admin/add-ses-configuration.tsx | 40 ++++ apps/web/src/app/(dashboard)/admin/page.tsx | 19 ++ .../(dashboard)/admin/ses-configurations.tsx | 65 ++++++ .../src/app/(dashboard)/dasboard-layout.tsx | 192 ++++++++++++++++++ .../(dashboard)/domains/[domainId]/page.tsx | 7 +- .../domains/[domainId]/send-test-mail.tsx | 18 +- .../app/(dashboard)/domains/add-domain.tsx | 61 +++++- .../app/(dashboard)/domains/domain-list.tsx | 4 +- .../app/(dashboard)/emails/email-details.tsx | 126 ++++++++++-- .../src/app/(dashboard)/emails/email-list.tsx | 1 + .../(dashboard)/emails/email-status-badge.tsx | 2 + apps/web/src/app/(dashboard)/layout.tsx | 186 +---------------- apps/web/src/app/api/health/route.ts | 3 - apps/web/src/app/api/ses_callback/route.ts | 29 +-- apps/web/src/app/layout.tsx | 7 - .../components/settings/AddSesSettings.tsx | 184 +++++++++++++++++ apps/web/src/env.js | 11 +- apps/web/src/instrumentation.ts | 19 ++ apps/web/src/lib/constants/example-codes.ts | 133 ++++++++++++ apps/web/src/lib/constants/ses-errors.ts | 11 + apps/web/src/providers/dashboard-provider.tsx | 20 +- apps/web/src/server/api/root.ts | 2 + apps/web/src/server/api/routers/admin.ts | 12 ++ apps/web/src/server/api/routers/domain.ts | 22 +- apps/web/src/server/auth.ts | 3 + apps/web/src/server/aws/ses.ts | 56 +++-- apps/web/src/server/aws/setup.ts | 99 --------- apps/web/src/server/aws/sns.ts | 14 +- .../server/public-api/api/emails/get-email.ts | 6 +- .../public-api/api/emails/send-email.ts | 8 +- apps/web/src/server/public-api/hono.ts | 2 +- apps/web/src/server/redis.ts | 11 + .../server/service/app-settings-service.ts | 38 ---- apps/web/src/server/service/domain-service.ts | 16 +- .../src/server/service/email-queue-service.ts | 135 ++++++++++++ apps/web/src/server/service/email-service.ts | 45 +++- apps/web/src/server/service/job-service.ts | 109 ---------- .../web/src/server/service/ses-hook-parser.ts | 26 +-- .../server/service/ses-settings-service.ts | 87 +++++--- apps/web/src/types/index.ts | 6 +- apps/web/src/utils/constants.ts | 9 - apps/web/src/utils/ses-utils.ts | 21 +- Dockerfile.prod => docker/Dockerfile | 5 +- docker/build.sh | 26 +++ docker/dev/compose.yml | 29 +++ docker-compose.yml => docker/prod/compose.yml | 29 +-- start.sh => docker/start.sh | 0 package.json | 7 +- packages/sdk/package.json | 3 +- packages/sdk/types/schema.d.ts | 12 +- packages/ui/src/code.tsx | 15 +- pnpm-lock.yaml | 175 +++++++++++++++- turbo.json | 2 - 67 files changed, 1771 insertions(+), 688 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.selfhost.example delete mode 100644 apps/web/prisma/migrations/20240429231118_add_is_beta_user/migration.sql delete mode 100644 apps/web/prisma/migrations/20240510004745_add_queue_enum/migration.sql delete mode 100644 apps/web/prisma/migrations/20240510004808_change_default_queued/migration.sql delete mode 100644 apps/web/prisma/migrations/20240515211820_add_reply_to/migration.sql delete mode 100644 apps/web/prisma/migrations/20240527202531_add_is_verifying/migration.sql delete mode 100644 apps/web/prisma/migrations/20240610073426_add_ses_settings/migration.sql rename apps/web/prisma/migrations/{20240420223207_init => 20240619211232_init}/migration.sql (79%) create mode 100644 apps/web/src/app/(dashboard)/admin/add-ses-configuration.tsx create mode 100644 apps/web/src/app/(dashboard)/admin/page.tsx create mode 100644 apps/web/src/app/(dashboard)/admin/ses-configurations.tsx create mode 100644 apps/web/src/app/(dashboard)/dasboard-layout.tsx create mode 100644 apps/web/src/components/settings/AddSesSettings.tsx create mode 100644 apps/web/src/instrumentation.ts create mode 100644 apps/web/src/lib/constants/example-codes.ts delete mode 100644 apps/web/src/server/aws/setup.ts create mode 100644 apps/web/src/server/redis.ts delete mode 100644 apps/web/src/server/service/app-settings-service.ts create mode 100644 apps/web/src/server/service/email-queue-service.ts delete mode 100644 apps/web/src/server/service/job-service.ts delete mode 100644 apps/web/src/utils/constants.ts rename Dockerfile.prod => docker/Dockerfile (97%) create mode 100644 docker/build.sh create mode 100644 docker/dev/compose.yml rename docker-compose.yml => docker/prod/compose.yml (65%) rename start.sh => docker/start.sh (100%) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9687261 --- /dev/null +++ b/.dockerignore @@ -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 \ No newline at end of file diff --git a/.env.selfhost.example b/.env.selfhost.example new file mode 100644 index 0000000..3ca00ba --- /dev/null +++ b/.env.selfhost.example @@ -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="" +GITHUB_SECRET="" + +# AWS details +AWS_DEFAULT_REGION="us-east-1" +AWS_SECRET_KEY="" +AWS_ACCESS_KEY="" + + + +DOCKER_OUTPUT=1 +NEXT_PUBLIC_IS_CLOUD=false +API_RATE_LIMIT=1 + +# used to send important error notification +DISCORD_WEBHOOK_URL="" diff --git a/.gitignore b/.gitignore index bfd321f..dbf76d9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules .env.development.local .env.test.local .env.production.local +.env.selfhost # Testing coverage diff --git a/apps/docs/api-reference/openapi.json b/apps/docs/api-reference/openapi.json index 8c16abe..0942847 100644 --- a/apps/docs/api-reference/openapi.json +++ b/apps/docs/api-reference/openapi.json @@ -6,7 +6,7 @@ }, "servers": [ { - "url": "https://app.unsend.dev/api" + "url": "https://test.ossapps.dev/api" } ], "components": { @@ -111,7 +111,7 @@ "schema": { "type": "string", "minLength": 3, - "example": "1212121" + "example": "cuiwqdj74rygf74" }, "required": true, "name": "emailId", @@ -133,7 +133,56 @@ "type": "number" }, "to": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "replyTo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "cc": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "bcc": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "from": { "type": "string" @@ -222,8 +271,19 @@ "type": "object", "properties": { "to": { - "type": "string", - "format": "email" + "anyOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "array", + "items": { + "type": "string", + "format": "email" + } + } + ] }, "from": { "type": "string", @@ -233,7 +293,49 @@ "type": "string" }, "replyTo": { - "type": "string" + "anyOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "array", + "items": { + "type": "string", + "format": "email" + } + } + ] + }, + "cc": { + "anyOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "array", + "items": { + "type": "string", + "format": "email" + } + } + ] + }, + "bcc": { + "anyOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "array", + "items": { + "type": "string", + "format": "email" + } + } + ] }, "text": { "type": "string" diff --git a/apps/web/next.config.js b/apps/web/next.config.js index bdadcc2..5e6f420 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -7,6 +7,11 @@ await import("./src/env.js"); /** @type {import("next").NextConfig} */ const config = { output: process.env.DOCKER_OUTPUT ? "standalone" : undefined, + experimental: { + instrumentationHook: true, + esmExternals: "loose", + serverComponentsExternalPackages: ["bullmq"], + }, }; export default config; diff --git a/apps/web/package.json b/apps/web/package.json index f950756..c3247b9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,7 +13,8 @@ "db:push": "prisma db push --skip-generate", "db:migrate-dev": "prisma migrate dev", "db:migrate-deploy": "prisma migrate deploy", - "db:studio": "prisma studio" + "db:studio": "prisma studio", + "db:migrate-reset": "prisma migrate reset" }, "dependencies": { "@auth/prisma-adapter": "^1.4.0", @@ -32,9 +33,11 @@ "@trpc/react-query": "next", "@trpc/server": "next", "@unsend/ui": "workspace:*", + "bullmq": "^5.8.2", "date-fns": "^3.6.0", "hono": "^4.2.2", "install": "^0.13.0", + "ioredis": "^5.4.1", "lucide-react": "^0.359.0", "mime-types": "^2.1.35", "nanoid": "^5.0.7", @@ -51,6 +54,7 @@ "server-only": "^0.0.1", "superjson": "^2.2.1", "tldts": "^6.1.16", + "ua-parser-js": "^1.0.38", "unsend": "workspace:*", "zod": "^3.22.4" }, @@ -61,6 +65,7 @@ "@types/node": "^20.11.20", "@types/react": "^18.2.57", "@types/react-dom": "^18.2.19", + "@types/ua-parser-js": "^0.7.39", "@typescript-eslint/eslint-plugin": "^7.1.1", "@typescript-eslint/parser": "^7.1.1", "@unsend/eslint-config": "workspace:*", diff --git a/apps/web/prisma/migrations/20240429231118_add_is_beta_user/migration.sql b/apps/web/prisma/migrations/20240429231118_add_is_beta_user/migration.sql deleted file mode 100644 index da32103..0000000 --- a/apps/web/prisma/migrations/20240429231118_add_is_beta_user/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "User" ADD COLUMN "isBetaUser" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/web/prisma/migrations/20240510004745_add_queue_enum/migration.sql b/apps/web/prisma/migrations/20240510004745_add_queue_enum/migration.sql deleted file mode 100644 index 19cbee4..0000000 --- a/apps/web/prisma/migrations/20240510004745_add_queue_enum/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- AlterEnum -ALTER TYPE "EmailStatus" ADD VALUE 'QUEUED'; - --- AlterTable -ALTER TABLE "Email" ADD COLUMN "attachments" TEXT; diff --git a/apps/web/prisma/migrations/20240510004808_change_default_queued/migration.sql b/apps/web/prisma/migrations/20240510004808_change_default_queued/migration.sql deleted file mode 100644 index 0bc7418..0000000 --- a/apps/web/prisma/migrations/20240510004808_change_default_queued/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "Email" ALTER COLUMN "latestStatus" SET DEFAULT 'QUEUED'; diff --git a/apps/web/prisma/migrations/20240515211820_add_reply_to/migration.sql b/apps/web/prisma/migrations/20240515211820_add_reply_to/migration.sql deleted file mode 100644 index e5b0a53..0000000 --- a/apps/web/prisma/migrations/20240515211820_add_reply_to/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "Email" ADD COLUMN "replyTo" TEXT; diff --git a/apps/web/prisma/migrations/20240527202531_add_is_verifying/migration.sql b/apps/web/prisma/migrations/20240527202531_add_is_verifying/migration.sql deleted file mode 100644 index 104676b..0000000 --- a/apps/web/prisma/migrations/20240527202531_add_is_verifying/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "Domain" ADD COLUMN "isVerifying" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/web/prisma/migrations/20240610073426_add_ses_settings/migration.sql b/apps/web/prisma/migrations/20240610073426_add_ses_settings/migration.sql deleted file mode 100644 index 6869687..0000000 --- a/apps/web/prisma/migrations/20240610073426_add_ses_settings/migration.sql +++ /dev/null @@ -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"); diff --git a/apps/web/prisma/migrations/20240420223207_init/migration.sql b/apps/web/prisma/migrations/20240619211232_init/migration.sql similarity index 79% rename from apps/web/prisma/migrations/20240420223207_init/migration.sql rename to apps/web/prisma/migrations/20240619211232_init/migration.sql index cbb4b23..0bdddae 100644 --- a/apps/web/prisma/migrations/20240420223207_init/migration.sql +++ b/apps/web/prisma/migrations/20240619211232_init/migration.sql @@ -8,7 +8,7 @@ CREATE TYPE "DomainStatus" AS ENUM ('NOT_STARTED', 'PENDING', 'SUCCESS', 'FAILED CREATE TYPE "ApiPermission" AS ENUM ('FULL', 'SENDING'); -- CreateEnum -CREATE TYPE "EmailStatus" AS ENUM ('SENT', 'OPENED', 'CLICKED', 'BOUNCED', 'COMPLAINED', 'DELIVERED', 'REJECTED', 'RENDERING_FAILURE', 'DELIVERY_DELAYED'); +CREATE TYPE "EmailStatus" AS ENUM ('QUEUED', 'SENT', 'OPENED', 'CLICKED', 'BOUNCED', 'COMPLAINED', 'DELIVERED', 'REJECTED', 'RENDERING_FAILURE', 'DELIVERY_DELAYED', 'FAILED'); -- CreateTable CREATE TABLE "AppSetting" ( @@ -18,6 +18,30 @@ CREATE TABLE "AppSetting" ( CONSTRAINT "AppSetting_pkey" PRIMARY KEY ("key") ); +-- CreateTable +CREATE TABLE "SesSetting" ( + "id" TEXT NOT NULL, + "region" TEXT NOT NULL, + "idPrefix" TEXT NOT NULL, + "topic" TEXT NOT NULL, + "topicArn" TEXT, + "callbackUrl" TEXT NOT NULL, + "callbackSuccess" BOOLEAN NOT NULL DEFAULT false, + "configGeneral" TEXT, + "configGeneralSuccess" BOOLEAN NOT NULL DEFAULT false, + "configClick" TEXT, + "configClickSuccess" BOOLEAN NOT NULL DEFAULT false, + "configOpen" TEXT, + "configOpenSuccess" BOOLEAN NOT NULL DEFAULT false, + "configFull" TEXT, + "configFullSuccess" BOOLEAN NOT NULL DEFAULT false, + "sesEmailRateLimit" INTEGER NOT NULL DEFAULT 1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SesSetting_pkey" PRIMARY KEY ("id") +); + -- CreateTable CREATE TABLE "Account" ( "id" TEXT NOT NULL, @@ -61,6 +85,7 @@ CREATE TABLE "User" ( "email" TEXT, "emailVerified" TIMESTAMP(3), "image" TEXT, + "isBetaUser" BOOLEAN NOT NULL DEFAULT false, CONSTRAINT "User_pkey" PRIMARY KEY ("id") ); @@ -97,6 +122,7 @@ CREATE TABLE "Domain" ( "dmarcAdded" BOOLEAN NOT NULL DEFAULT false, "errorMessage" TEXT, "subdomain" TEXT, + "isVerifying" BOOLEAN NOT NULL DEFAULT false, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, @@ -122,28 +148,38 @@ CREATE TABLE "ApiKey" ( CREATE TABLE "Email" ( "id" TEXT NOT NULL, "sesEmailId" TEXT, - "to" TEXT NOT NULL, "from" TEXT NOT NULL, + "to" TEXT[], + "replyTo" TEXT[], + "cc" TEXT[], + "bcc" TEXT[], "subject" TEXT NOT NULL, "text" TEXT, "html" TEXT, - "latestStatus" "EmailStatus" NOT NULL DEFAULT 'SENT', + "latestStatus" "EmailStatus" NOT NULL DEFAULT 'QUEUED', "teamId" INTEGER NOT NULL, "domainId" INTEGER, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, + "attachments" TEXT, CONSTRAINT "Email_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "EmailEvent" ( + "id" TEXT NOT NULL, "emailId" TEXT NOT NULL, "status" "EmailStatus" NOT NULL, "data" JSONB, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "EmailEvent_pkey" PRIMARY KEY ("id") ); +-- CreateIndex +CREATE UNIQUE INDEX "SesSetting_region_key" ON "SesSetting"("region"); + -- CreateIndex CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); @@ -171,9 +207,6 @@ CREATE UNIQUE INDEX "ApiKey_tokenHash_key" ON "ApiKey"("tokenHash"); -- CreateIndex CREATE UNIQUE INDEX "Email_sesEmailId_key" ON "Email"("sesEmailId"); --- CreateIndex -CREATE UNIQUE INDEX "EmailEvent_emailId_status_key" ON "EmailEvent"("emailId", "status"); - -- AddForeignKey ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 4453a0a..ffcab7f 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -35,6 +35,7 @@ model SesSetting { configOpenSuccess Boolean @default(false) configFull String? configFullSuccess Boolean @default(false) + sesEmailRateLimit Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -162,23 +163,26 @@ model ApiKey { enum EmailStatus { QUEUED SENT + BOUNCED + DELIVERED OPENED CLICKED - BOUNCED COMPLAINED - DELIVERED REJECTED RENDERING_FAILURE DELIVERY_DELAYED + FAILED } model Email { id String @id @default(cuid()) sesEmailId String? @unique - to String from String + to String[] + replyTo String[] + cc String[] + bcc String[] subject String - replyTo String? text String? html String? latestStatus EmailStatus @default(QUEUED) @@ -192,11 +196,10 @@ model Email { } model EmailEvent { + id String @id @default(cuid()) emailId String status EmailStatus data Json? createdAt DateTime @default(now()) email Email @relation(fields: [emailId], references: [id], onDelete: Cascade) - - @@unique([emailId, status]) } diff --git a/apps/web/src/app/(dashboard)/admin/add-ses-configuration.tsx b/apps/web/src/app/(dashboard)/admin/add-ses-configuration.tsx new file mode 100644 index 0000000..3458642 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/add-ses-configuration.tsx @@ -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 ( + (_open !== open ? setOpen(_open) : null)} + > + + + + + + Add a new SES configuration + +
+ setOpen(false)} /> +
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/admin/page.tsx b/apps/web/src/app/(dashboard)/admin/page.tsx new file mode 100644 index 0000000..f9e3d68 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import AddSesConfiguration from "./add-ses-configuration"; +import SesConfigurations from "./ses-configurations"; + +export default function ApiKeysPage() { + return ( +
+
+

Admin

+ +
+
+

SES Configurations

+ +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/admin/ses-configurations.tsx b/apps/web/src/app/(dashboard)/admin/ses-configurations.tsx new file mode 100644 index 0000000..16fd684 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/ses-configurations.tsx @@ -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 ( +
+
+ + + + Region + Callback URL + Callback status + Created at + + + + {sesSettingsQuery.isLoading ? ( + + + + + + ) : sesSettingsQuery.data?.length === 0 ? ( + + +

No SES configurations added

+
+
+ ) : ( + sesSettingsQuery.data?.map((sesSetting) => ( + + {sesSetting.region} + {sesSetting.callbackUrl} + + {sesSetting.callbackSuccess ? "Success" : "Failed"} + + + {formatDistanceToNow(sesSetting.createdAt)} ago + + + )) + )} +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/dasboard-layout.tsx b/apps/web/src/app/(dashboard)/dasboard-layout.tsx new file mode 100644 index 0000000..77bd9ab --- /dev/null +++ b/apps/web/src/app/(dashboard)/dasboard-layout.tsx @@ -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 ( +
+
+
+
+ + Unsend + + + Early access + +
+
+ +
+
+
+
+
+
+ + + + + + +
+
+
+ + + + + + My Account + + Settings + Support + + Logout + + +
+
+
+ {children} +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx b/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx index 915c0da..247a572 100644 --- a/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx +++ b/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx @@ -26,6 +26,7 @@ import DeleteDomain from "./delete-domain"; import SendTestMail from "./send-test-mail"; import { Button } from "@unsend/ui/src/button"; import Link from "next/link"; +import { toast } from "@unsend/ui/src/toaster"; export default function DomainItemPage({ params, @@ -245,7 +246,8 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => { { id: domain.id, clickTracking: !clickTracking }, { onSuccess: () => { - utils.domain.domains.invalidate(); + utils.domain.invalidate(); + toast.success("Click tracking updated"); }, } ); @@ -257,7 +259,8 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => { { id: domain.id, openTracking: !openTracking }, { onSuccess: () => { - utils.domain.domains.invalidate(); + utils.domain.invalidate(); + toast.success("Open tracking updated"); }, } ); diff --git a/apps/web/src/app/(dashboard)/domains/[domainId]/send-test-mail.tsx b/apps/web/src/app/(dashboard)/domains/[domainId]/send-test-mail.tsx index 01afffb..0d4b7d9 100644 --- a/apps/web/src/app/(dashboard)/domains/[domainId]/send-test-mail.tsx +++ b/apps/web/src/app/(dashboard)/domains/[domainId]/send-test-mail.tsx @@ -14,6 +14,8 @@ import { Domain } from "@prisma/client"; import { toast } from "@unsend/ui/src/toaster"; import { SendHorizonal } from "lucide-react"; import { Code } from "@unsend/ui/src/code"; +import { useSession } from "next-auth/react"; +import { getSendTestEmailCode } from "~/lib/constants/example-codes"; const jsCode = `const requestOptions = { method: "POST", @@ -112,6 +114,8 @@ export const SendTestMail: React.FC<{ domain: Domain }> = ({ domain }) => { const sendTestEmailFromDomainMutation = api.domain.sendTestEmailFromDomain.useMutation(); + const { data: session } = useSession(); + const utils = api.useUtils(); function handleSendTestEmail() { @@ -145,12 +149,14 @@ export const SendTestMail: React.FC<{ domain: Domain }> = ({ domain }) => { Send test email hello,

Unsend is the best open source sending platform

check out unsend.dev", + })} codeClassName="max-w-[38rem] h-[20rem]" />

diff --git a/apps/web/src/app/(dashboard)/domains/add-domain.tsx b/apps/web/src/app/(dashboard)/domains/add-domain.tsx index 22b4ea8..bd6a0fe 100644 --- a/apps/web/src/app/(dashboard)/domains/add-domain.tsx +++ b/apps/web/src/app/(dashboard)/domains/add-domain.tsx @@ -27,17 +27,37 @@ import * as tldts from "tldts"; import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@unsend/ui/src/select"; +import { toast } from "@unsend/ui/src/toaster"; const domainSchema = z.object({ - domain: z.string({ required_error: "Domain is required" }), + region: z.string({ required_error: "Region is required" }).min(1, { + message: "Region is required", + }), + domain: z.string({ required_error: "Domain is required" }).min(1, { + message: "Domain is required", + }), }); export default function AddDomain() { const [open, setOpen] = useState(false); + + const regionQuery = api.domain.getAvailableRegions.useQuery(); + const addDomainMutation = api.domain.createDomain.useMutation(); const domainForm = useForm>({ resolver: zodResolver(domainSchema), + defaultValues: { + region: "", + domain: "", + }, }); const utils = api.useUtils(); @@ -56,6 +76,7 @@ export default function AddDomain() { addDomainMutation.mutate( { name: values.domain, + region: values.region, }, { onSuccess: async (data) => { @@ -63,6 +84,9 @@ export default function AddDomain() { await router.push(`/domains/${data.id}`); setOpen(false); }, + onError: async (error) => { + toast.error(error.message); + }, } ); } @@ -108,6 +132,41 @@ export default function AddDomain() { )} /> + ( + + Region + + {formState.errors.region ? ( + + ) : ( + + Select the region from where the email is sent{" "} + + )} + + )} + /> +
diff --git a/apps/web/src/app/(dashboard)/emails/email-details.tsx b/apps/web/src/app/(dashboard)/emails/email-details.tsx index 9a65881..0aa92bb 100644 --- a/apps/web/src/app/(dashboard)/emails/email-details.tsx +++ b/apps/web/src/app/(dashboard)/emails/email-details.tsx @@ -1,14 +1,22 @@ "use client"; +import { UAParser } from "ua-parser-js"; import { api } from "~/trpc/react"; import { Separator } from "@unsend/ui/src/separator"; import { EmailStatusBadge, EmailStatusIcon } from "./email-status-badge"; import { formatDate } from "date-fns"; import { EmailStatus } from "@prisma/client"; import { JsonValue } from "@prisma/client/runtime/library"; -import { SesBounce, SesDeliveryDelay } from "~/types/aws-types"; +import { + SesBounce, + SesClick, + SesComplaint, + SesDeliveryDelay, + SesOpen, +} from "~/types/aws-types"; import { BOUNCE_ERROR_MESSAGES, + COMPLAINT_ERROR_MESSAGES, DELIVERY_DELAY_ERRORS, } from "~/lib/constants/ses-errors"; @@ -39,7 +47,7 @@ export default function EmailDetails({ emailId }: { emailId: string }) { Subject {emailQuery.data?.subject}
-
+
-
+
Events History
-
+
-
+
{emailQuery.data?.emailEvents.map((evt) => ( -
+
-
+
@@ -104,26 +115,88 @@ const EmailStatusText = ({ _errorData.bounceType; return ( -
+

{getErrorMessage(_errorData)}

-
-
-

Type

-

{_errorData.bounceType}

+
+
+
+

Type

+

{_errorData.bounceType}

+
+
+

Sub Type

+

{_errorData.bounceSubType}

+
-

Sub Type

-

{_errorData.bounceSubType}

+

SMTP response

+

{_errorData.bouncedRecipients[0]?.diagnosticCode}

-
-

SMTP response

-

{_errorData.bouncedRecipients[0]?.diagnosticCode}

-
+
+ ); + } else if (status === "FAILED") { + const _errorData = data as unknown as { error: string }; + return
{_errorData.error}
; + } else if (status === "OPENED") { + const _data = data as unknown as SesOpen; + const userAgent = getUserAgent(_data.userAgent); + + return ( +
+
+ {userAgent.os.name ? ( +
+

OS

+

{userAgent.os.name}

+
+ ) : null} + {userAgent.browser.name ? ( +
+

Browser

+

{userAgent.browser.name}

+
+ ) : null} +
+
+ ); + } else if (status === "CLICKED") { + const _data = data as unknown as SesClick; + const userAgent = getUserAgent(_data.userAgent); + + return ( +
+
+ {userAgent.os.name ? ( +
+

OS

+

{userAgent.os.name}

+
+ ) : null} + {userAgent.browser.name ? ( +
+

Browser

+

{userAgent.browser.name}

+
+ ) : null} +
+
+

URL

+

{_data.link}

+
+
+ ); + } else if (status === "COMPLAINED") { + const _errorData = data as unknown as SesComplaint; + + return ( +
+

{getComplaintMessage(_errorData.complaintFeedbackType)}

); } - return
{status}
; + + return
{status}
; }; const getErrorMessage = (data: SesBounce) => { @@ -148,3 +221,18 @@ const getErrorMessage = (data: SesBounce) => { return BOUNCE_ERROR_MESSAGES.Undetermined; } }; + +const getComplaintMessage = (errorType: string) => { + return COMPLAINT_ERROR_MESSAGES[ + errorType as keyof typeof COMPLAINT_ERROR_MESSAGES + ]; +}; + +const getUserAgent = (userAgent: string) => { + const parser = new UAParser(userAgent); + return { + browser: parser.getBrowser(), + os: parser.getOS(), + device: parser.getDevice(), + }; +}; diff --git a/apps/web/src/app/(dashboard)/emails/email-list.tsx b/apps/web/src/app/(dashboard)/emails/email-list.tsx index 166c836..fbec4cb 100644 --- a/apps/web/src/app/(dashboard)/emails/email-list.tsx +++ b/apps/web/src/app/(dashboard)/emails/email-list.tsx @@ -190,6 +190,7 @@ const EmailIcon: React.FC<{ status: EmailStatus }> = ({ status }) => { //
); case "BOUNCED": + case "FAILED": return ( //
diff --git a/apps/web/src/app/(dashboard)/emails/email-status-badge.tsx b/apps/web/src/app/(dashboard)/emails/email-status-badge.tsx index 0a0e9f0..7002293 100644 --- a/apps/web/src/app/(dashboard)/emails/email-status-badge.tsx +++ b/apps/web/src/app/(dashboard)/emails/email-status-badge.tsx @@ -12,6 +12,7 @@ export const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({ badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10"; break; case "BOUNCED": + case "FAILED": badgeColor = "bg-red-500/10 text-red-600 border-red-600/10"; break; case "CLICKED": @@ -51,6 +52,7 @@ export const EmailStatusIcon: React.FC<{ status: EmailStatus }> = ({ insideColor = "bg-emerald-500"; break; case "BOUNCED": + case "FAILED": outsideColor = "bg-red-500/30"; insideColor = "bg-red-500"; break; diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx index 3229567..8a2c4a2 100644 --- a/apps/web/src/app/(dashboard)/layout.tsx +++ b/apps/web/src/app/(dashboard)/layout.tsx @@ -1,37 +1,6 @@ -import Link from "next/link"; -import { - BookOpenText, - BookUser, - CircleUser, - Code, - Globe, - Home, - LayoutDashboard, - LineChart, - LogOut, - Mail, - Menu, - Package, - Package2, - ShoppingCart, - Users, - Volume2, -} from "lucide-react"; -import { Button } from "@unsend/ui/src/button"; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@unsend/ui/src/dropdown-menu"; -import { Sheet, SheetContent, SheetTrigger } from "@unsend/ui/src/sheet"; - -import { LogoutButton, NavButton } from "./nav-button"; import { DashboardProvider } from "~/providers/dashboard-provider"; import { NextAuthProvider } from "~/providers/next-auth"; +import { DashboardLayout } from "./dasboard-layout"; export const dynamic = "force-static"; @@ -43,158 +12,7 @@ export default function AuthenticatedDashboardLayout({ return ( -
-
-
-
- - Unsend - - - Early access - -
-
- -
-
-
-
-
-
- - - - - - -
-
-
- - - - - - My Account - - Settings - Support - - Logout - - -
-
-
- {children} -
-
-
-
+ {children}
); diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts index 4584839..abc938c 100644 --- a/apps/web/src/app/api/health/route.ts +++ b/apps/web/src/app/api/health/route.ts @@ -1,8 +1,5 @@ -import { setupAws } from "~/server/aws/setup"; - export const dynamic = "force-dynamic"; export async function GET() { - await setupAws(); return Response.json({ data: "Healthy" }); } diff --git a/apps/web/src/app/api/ses_callback/route.ts b/apps/web/src/app/api/ses_callback/route.ts index 7169dd7..672d4ac 100644 --- a/apps/web/src/app/api/ses_callback/route.ts +++ b/apps/web/src/app/api/ses_callback/route.ts @@ -1,11 +1,11 @@ import { db } from "~/server/db"; -import { AppSettingsService } from "~/server/service/app-settings-service"; import { parseSesHook } from "~/server/service/ses-hook-parser"; +import { SesSettingsService } from "~/server/service/ses-settings-service"; import { SnsNotificationMessage } from "~/types/aws-types"; -import { APP_SETTINGS } from "~/utils/constants"; -export async function GET(req: Request) { - console.log("GET", req); +export const dynamic = "force-dynamic"; + +export async function GET() { return Response.json({ data: "Hello" }); } @@ -14,10 +14,6 @@ export async function POST(req: Request) { console.log(data, data.Message); - if (isFromUnsend(data)) { - return Response.json({ data: "success" }); - } - const isEventValid = await checkEventValidity(data); console.log("isEventValid: ", isEventValid); @@ -72,26 +68,17 @@ async function handleSubscription(message: any) { }, }); + SesSettingsService.invalidateCache(); + return Response.json({ data: "Success" }); } -// A simple check to ensure that the event is from the correct topic -function isFromUnsend({ fromUnsend }: { fromUnsend: boolean }) { - if (fromUnsend) { - return true; - } - - return false; -} - // A simple check to ensure that the event is from the correct topic async function checkEventValidity(message: SnsNotificationMessage) { const { TopicArn } = message; - const configuredTopicArn = await AppSettingsService.getSetting( - APP_SETTINGS.SNS_TOPIC_ARN - ); + const configuredTopicArn = await SesSettingsService.getTopicArns(); - if (TopicArn !== configuredTopicArn) { + if (!configuredTopicArn.includes(TopicArn)) { return false; } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 7678d05..6dbd520 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -6,7 +6,6 @@ import { Toaster } from "@unsend/ui/src/toaster"; import { TRPCReactProvider } from "~/trpc/react"; import { Metadata } from "next"; -import { getBoss } from "~/server/service/job-service"; const inter = Inter({ subsets: ["latin"], @@ -24,12 +23,6 @@ export default async function RootLayout({ }: { children: React.ReactNode; }) { - /** - * Because I don't know a better way to call this during server startup. - * This is a temporary fix to ensure that the boss is running. - */ - // await getBoss(); - return ( diff --git a/apps/web/src/components/settings/AddSesSettings.tsx b/apps/web/src/components/settings/AddSesSettings.tsx new file mode 100644 index 0000000..31a3f42 --- /dev/null +++ b/apps/web/src/components/settings/AddSesSettings.tsx @@ -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 = ({ onSuccess }) => { + return ( +
+
+
+

+ Add SES Settings +

+
+ +
+
+ ); +}; + +export const AddSesSettingsForm: React.FC = ({ + onSuccess, +}) => { + const addSesSettings = api.admin.addSesSettings.useMutation(); + + const utils = api.useUtils(); + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + region: "", + unsendUrl: "", + sendRate: 1, + }, + }); + + function onSubmit(data: z.infer) { + 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 ( +
+ + ( + + Region + + { + onRegionInputOutOfFocus(); + field.onBlur(); + }} + /> + + {formState.errors.region ? ( + + ) : ( + The region of the SES account + )} + + )} + /> + ( + + Callback URL + + + + {formState.errors.unsendUrl ? ( + + ) : ( + + This url should be accessible from the internet. Will be + called from SES + + )} + + )} + /> + ( + + Send Rate + + + + {formState.errors.sendRate ? ( + + ) : ( + + The number of emails to send per second. + + )} + + )} + /> + + + + ); +}; diff --git a/apps/web/src/env.js b/apps/web/src/env.js index e122881..d0e5b0d 100644 --- a/apps/web/src/env.js +++ b/apps/web/src/env.js @@ -1,3 +1,4 @@ +import { EmailStatus } from "@prisma/client"; import { createEnv } from "@t3-oss/env-nextjs"; import { z } from "zod"; @@ -32,21 +33,19 @@ export const env = createEnv({ GITHUB_SECRET: z.string(), AWS_ACCESS_KEY: z.string(), AWS_SECRET_KEY: z.string(), - APP_URL: z.string().optional(), - SNS_TOPIC: z.string(), UNSEND_API_KEY: z.string().optional(), UNSEND_URL: z.string().optional(), GOOGLE_CLIENT_ID: z.string().optional(), GOOGLE_CLIENT_SECRET: z.string().optional(), - SES_QUEUE_LIMIT: z.string().transform((str) => parseInt(str, 10)), AWS_DEFAULT_REGION: z.string().default("us-east-1"), API_RATE_LIMIT: z .string() .transform((str) => parseInt(str, 10)) - .default(2), + .default(1), FROM_EMAIL: z.string().optional(), ADMIN_EMAIL: z.string().optional(), DISCORD_WEBHOOK_URL: z.string().optional(), + REDIS_URL: z.string(), }, /** @@ -72,18 +71,16 @@ export const env = createEnv({ GITHUB_SECRET: process.env.GITHUB_SECRET, AWS_ACCESS_KEY: process.env.AWS_ACCESS_KEY, AWS_SECRET_KEY: process.env.AWS_SECRET_KEY, - APP_URL: process.env.APP_URL, - SNS_TOPIC: process.env.SNS_TOPIC, UNSEND_API_KEY: process.env.UNSEND_API_KEY, UNSEND_URL: process.env.UNSEND_URL, GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, - SES_QUEUE_LIMIT: process.env.SES_QUEUE_LIMIT, AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION, API_RATE_LIMIT: process.env.API_RATE_LIMIT, NEXT_PUBLIC_IS_CLOUD: process.env.NEXT_PUBLIC_IS_CLOUD, ADMIN_EMAIL: process.env.ADMIN_EMAIL, DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL, + REDIS_URL: process.env.REDIS_URL, }, /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially diff --git a/apps/web/src/instrumentation.ts b/apps/web/src/instrumentation.ts new file mode 100644 index 0000000..1d7893c --- /dev/null +++ b/apps/web/src/instrumentation.ts @@ -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; + } +} diff --git a/apps/web/src/lib/constants/example-codes.ts b/apps/web/src/lib/constants/example-codes.ts new file mode 100644 index 0000000..5dae1db --- /dev/null +++ b/apps/web/src/lib/constants/example-codes.ts @@ -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 => { + 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: ` "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}"}'`, + }, + ]; +}; diff --git a/apps/web/src/lib/constants/ses-errors.ts b/apps/web/src/lib/constants/ses-errors.ts index 1aced89..fbd1b94 100644 --- a/apps/web/src/lib/constants/ses-errors.ts +++ b/apps/web/src/lib/constants/ses-errors.ts @@ -44,3 +44,14 @@ export const BOUNCE_ERROR_MESSAGES = { "Unsend received an attachment rejected bounce. You may be able to successfully send to this recipient if you remove or change the attachment.", }, }; + +export const COMPLAINT_ERROR_MESSAGES = { + abuse: "Indicates unsolicited email or some other kind of email abuse.", + "auth-failure": "Email authentication failure report.", + fraud: "Indicates some kind of fraud or phishing activity.", + "not-spam": + "Indicates that the entity providing the report does not consider the message to be spam. This may be used to correct a message that was incorrectly tagged or categorized as spam.", + other: + "Indicates any other feedback that does not fit into other registered types.", + virus: "Reports that a virus is found in the originating message.", +}; diff --git a/apps/web/src/providers/dashboard-provider.tsx b/apps/web/src/providers/dashboard-provider.tsx index e0395d1..00f93cd 100644 --- a/apps/web/src/providers/dashboard-provider.tsx +++ b/apps/web/src/providers/dashboard-provider.tsx @@ -1,7 +1,10 @@ "use client"; +import { useSession } from "next-auth/react"; import { FullScreenLoading } from "~/components/FullScreenLoading"; +import { AddSesSettings } from "~/components/settings/AddSesSettings"; import CreateTeam from "~/components/team/CreateTeam"; +import { env } from "~/env"; import { api } from "~/trpc/react"; export const DashboardProvider = ({ @@ -9,12 +12,27 @@ export const DashboardProvider = ({ }: { children: React.ReactNode; }) => { + const { data: session } = useSession(); const { data: teams, status } = api.team.getTeams.useQuery(); + const { data: settings, status: settingsStatus } = + api.admin.getSesSettings.useQuery(undefined, { + enabled: !env.NEXT_PUBLIC_IS_CLOUD || session?.user.isAdmin, + }); - if (status === "pending") { + if ( + status === "pending" || + (settingsStatus === "pending" && !env.NEXT_PUBLIC_IS_CLOUD) + ) { return ; } + if ( + settings?.length === 0 && + (!env.NEXT_PUBLIC_IS_CLOUD || session?.user.isAdmin) + ) { + return ; + } + if (!teams || teams.length === 0) { return ; } diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts index 4ea876d..693effa 100644 --- a/apps/web/src/server/api/root.ts +++ b/apps/web/src/server/api/root.ts @@ -3,6 +3,7 @@ import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; import { apiRouter } from "./routers/api"; import { emailRouter } from "./routers/email"; import { teamRouter } from "./routers/team"; +import { adminRouter } from "./routers/admin"; /** * This is the primary router for your server. @@ -14,6 +15,7 @@ export const appRouter = createTRPCRouter({ apiKey: apiRouter, email: emailRouter, team: teamRouter, + admin: adminRouter, }); // export type definition of API diff --git a/apps/web/src/server/api/routers/admin.ts b/apps/web/src/server/api/routers/admin.ts index 4ee4a08..d1b77d8 100644 --- a/apps/web/src/server/api/routers/admin.ts +++ b/apps/web/src/server/api/routers/admin.ts @@ -3,12 +3,24 @@ import { env } from "~/env"; import { createTRPCRouter, adminProcedure } from "~/server/api/trpc"; import { SesSettingsService } from "~/server/service/ses-settings-service"; +import { getAccount } from "~/server/aws/ses"; export const adminRouter = createTRPCRouter({ getSesSettings: adminProcedure.query(async () => { return SesSettingsService.getAllSettings(); }), + getQuotaForRegion: adminProcedure + .input( + z.object({ + region: z.string(), + }) + ) + .query(async ({ input }) => { + const acc = await getAccount(input.region); + return acc.SendQuota?.MaxSendRate; + }), + addSesSettings: adminProcedure .input( z.object({ diff --git a/apps/web/src/server/api/routers/domain.ts b/apps/web/src/server/api/routers/domain.ts index 4b55312..8f5b0e2 100644 --- a/apps/web/src/server/api/routers/domain.ts +++ b/apps/web/src/server/api/routers/domain.ts @@ -1,6 +1,10 @@ import { z } from "zod"; -import { createTRPCRouter, teamProcedure } from "~/server/api/trpc"; +import { + createTRPCRouter, + teamProcedure, + protectedProcedure, +} from "~/server/api/trpc"; import { db } from "~/server/db"; import { createDomain, @@ -9,12 +13,18 @@ import { updateDomain, } from "~/server/service/domain-service"; import { sendEmail } from "~/server/service/email-service"; +import { SesSettingsService } from "~/server/service/ses-settings-service"; export const domainRouter = createTRPCRouter({ + getAvailableRegions: protectedProcedure.query(async () => { + const settings = await SesSettingsService.getAllSettings(); + return settings.map((setting) => setting.region); + }), + createDomain: teamProcedure - .input(z.object({ name: z.string() })) + .input(z.object({ name: z.string(), region: z.string() })) .mutation(async ({ ctx, input }) => { - return createDomain(ctx.team.id, input.name); + return createDomain(ctx.team.id, input.name, input.region); }), startVerification: teamProcedure @@ -93,9 +103,9 @@ export const domainRouter = createTRPCRouter({ teamId: team.id, to: user.email, from: `hello@${domain.name}`, - subject: "Test mail", - text: "Hello this is a test mail", - html: "

Hello this is a test mail

", + subject: "Unsend test email", + text: "hello,\n\nUnsend is the best open source sending platform\n\ncheck out https://unsend.dev", + html: "

hello,

Unsend is the best open source sending platform

check out unsend.dev", }); } ), diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index b297c42..0ebee85 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -25,6 +25,7 @@ declare module "next-auth" { user: { id: number; isBetaUser: boolean; + isAdmin: boolean; // ...other properties // role: UserRole; } & DefaultSession["user"]; @@ -34,6 +35,7 @@ declare module "next-auth" { interface User { id: number; isBetaUser: boolean; + isAdmin: boolean; } } @@ -86,6 +88,7 @@ export const authOptions: NextAuthOptions = { ...session.user, id: user.id, isBetaUser: user.isBetaUser, + isAdmin: user.email === env.ADMIN_EMAIL, }, }), }, diff --git a/apps/web/src/server/aws/ses.ts b/apps/web/src/server/aws/ses.ts index ba04da7..406a9df 100644 --- a/apps/web/src/server/aws/ses.ts +++ b/apps/web/src/server/aws/ses.ts @@ -8,14 +8,14 @@ import { CreateConfigurationSetEventDestinationCommand, CreateConfigurationSetCommand, EventType, + GetAccountCommand, } from "@aws-sdk/client-sesv2"; import { generateKeyPairSync } from "crypto"; import mime from "mime-types"; import { env } from "~/env"; import { EmailContent } from "~/types"; -import { APP_SETTINGS } from "~/utils/constants"; -function getSesClient(region = "us-east-1") { +function getSesClient(region: string) { return new SESv2Client({ region: region, credentials: { @@ -51,7 +51,7 @@ function generateKeyPair() { return { privateKey: base64PrivateKey, publicKey: base64PublicKey }; } -export async function addDomain(domain: string, region = "us-east-1") { +export async function addDomain(domain: string, region: string) { const sesClient = getSesClient(region); const { privateKey, publicKey } = generateKeyPair(); @@ -61,7 +61,6 @@ export async function addDomain(domain: string, region = "us-east-1") { DomainSigningSelector: "unsend", DomainSigningPrivateKey: privateKey, }, - ConfigurationSetName: APP_SETTINGS.SES_CONFIGURATION_GENERAL, }); const response = await sesClient.send(command); @@ -84,7 +83,7 @@ export async function addDomain(domain: string, region = "us-east-1") { return publicKey; } -export async function deleteDomain(domain: string, region = "us-east-1") { +export async function deleteDomain(domain: string, region: string) { const sesClient = getSesClient(region); const command = new DeleteEmailIdentityCommand({ EmailIdentity: domain, @@ -93,7 +92,7 @@ export async function deleteDomain(domain: string, region = "us-east-1") { return response.$metadata.httpStatusCode === 200; } -export async function getDomainIdentity(domain: string, region = "us-east-1") { +export async function getDomainIdentity(domain: string, region: string) { const sesClient = getSesClient(region); const command = new GetEmailIdentityCommand({ EmailIdentity: domain, @@ -106,21 +105,29 @@ export async function sendEmailThroughSes({ to, from, subject, + cc, + bcc, text, html, replyTo, - region = "us-east-1", + region, configurationSetName, -}: EmailContent & { - region?: string; +}: Partial & { + region: string; configurationSetName: string; + cc?: string[]; + bcc?: string[]; + replyTo?: string[]; + to?: string[]; }) { const sesClient = getSesClient(region); const command = new SendEmailCommand({ FromEmailAddress: from, - ReplyToAddresses: replyTo ? [replyTo] : undefined, + ReplyToAddresses: replyTo ? replyTo : undefined, Destination: { - ToAddresses: [to], + ToAddresses: to, + CcAddresses: cc, + BccAddresses: bcc, }, Content: { // EmailContent @@ -153,7 +160,7 @@ export async function sendEmailThroughSes({ return response.MessageId; } catch (error) { console.error("Failed to send email", error); - throw new Error("Failed to send email"); + throw error; } } @@ -163,21 +170,29 @@ export async function sendEmailWithAttachments({ from, subject, replyTo, + cc, + bcc, // eslint-disable-next-line no-unused-vars text, html, attachments, - region = "us-east-1", + region, configurationSetName, -}: EmailContent & { - region?: string; +}: Partial & { + region: string; configurationSetName: string; attachments: { filename: string; content: string }[]; + cc?: string[]; + bcc?: string[]; + replyTo?: string[]; + to?: string[]; }) { const sesClient = getSesClient(region); const boundary = "NextPart"; let rawEmail = `From: ${from}\n`; - rawEmail += `To: ${to}\n`; + rawEmail += `To: ${Array.isArray(to) ? to.join(", ") : to}\n`; + rawEmail += `Cc: ${cc ? cc.join(", ") : ""}\n`; + rawEmail += `Bcc: ${bcc ? bcc.join(", ") : ""}\n`; rawEmail += `Reply-To: ${replyTo}\n`; rawEmail += `Subject: ${subject}\n`; rawEmail += `MIME-Version: 1.0\n`; @@ -217,11 +232,18 @@ export async function sendEmailWithAttachments({ } } +export async function getAccount(region: string) { + const client = getSesClient(region); + const input = new GetAccountCommand({}); + const response = await client.send(input); + return response; +} + export async function addWebhookConfiguration( configName: string, topicArn: string, eventTypes: EventType[], - region = "us-east-1" + region: string ) { const sesClient = getSesClient(region); diff --git a/apps/web/src/server/aws/setup.ts b/apps/web/src/server/aws/setup.ts deleted file mode 100644 index dea9de3..0000000 --- a/apps/web/src/server/aws/setup.ts +++ /dev/null @@ -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()); - } -} diff --git a/apps/web/src/server/aws/sns.ts b/apps/web/src/server/aws/sns.ts index ebd8959..851a3b5 100644 --- a/apps/web/src/server/aws/sns.ts +++ b/apps/web/src/server/aws/sns.ts @@ -5,7 +5,7 @@ import { } from "@aws-sdk/client-sns"; import { env } from "~/env"; -function getSnsClient(region = "us-east-1") { +function getSnsClient(region: string) { return new SNSClient({ region: region, credentials: { @@ -15,8 +15,8 @@ function getSnsClient(region = "us-east-1") { }); } -export async function createTopic(topic: string) { - const client = getSnsClient(); +export async function createTopic(topic: string, region: string) { + const client = getSnsClient(region); const command = new CreateTopicCommand({ Name: topic, }); @@ -25,13 +25,17 @@ export async function createTopic(topic: string) { return data.TopicArn; } -export async function subscribeEndpoint(topicArn: string, endpointUrl: string) { +export async function subscribeEndpoint( + topicArn: string, + endpointUrl: string, + region: string +) { const subscribeCommand = new SubscribeCommand({ Protocol: "https", TopicArn: topicArn, Endpoint: endpointUrl, }); - const client = getSnsClient(); + const client = getSnsClient(region); const data = await client.send(subscribeCommand); console.log(data.SubscriptionArn); diff --git a/apps/web/src/server/public-api/api/emails/get-email.ts b/apps/web/src/server/public-api/api/emails/get-email.ts index dacff34..6d6c650 100644 --- a/apps/web/src/server/public-api/api/emails/get-email.ts +++ b/apps/web/src/server/public-api/api/emails/get-email.ts @@ -1,7 +1,6 @@ import { createRoute, z } from "@hono/zod-openapi"; import { PublicAPIApp } from "~/server/public-api/hono"; import { getTeamFromToken } from "~/server/public-api/auth"; -import { sendEmail } from "~/server/service/email-service"; import { db } from "~/server/db"; import { EmailStatus } from "@prisma/client"; import { UnsendApiError } from "../../api-error"; @@ -30,7 +29,10 @@ const route = createRoute({ schema: z.object({ id: z.string(), teamId: z.number(), - to: z.string(), + to: z.string().or(z.array(z.string())), + replyTo: z.string().or(z.array(z.string())).optional(), + cc: z.string().or(z.array(z.string())).optional(), + bcc: z.string().or(z.array(z.string())).optional(), from: z.string(), subject: z.string(), html: z.string().nullable(), diff --git a/apps/web/src/server/public-api/api/emails/send-email.ts b/apps/web/src/server/public-api/api/emails/send-email.ts index 6ce1484..07b7571 100644 --- a/apps/web/src/server/public-api/api/emails/send-email.ts +++ b/apps/web/src/server/public-api/api/emails/send-email.ts @@ -12,10 +12,12 @@ const route = createRoute({ content: { "application/json": { schema: z.object({ - to: z.string().email(), - from: z.string().email(), + to: z.string().or(z.array(z.string())), + from: z.string(), subject: z.string(), - replyTo: z.string().optional(), + replyTo: z.string().or(z.array(z.string())).optional(), + cc: z.string().or(z.array(z.string())).optional(), + bcc: z.string().or(z.array(z.string())).optional(), text: z.string().optional(), html: z.string().optional(), attachments: z diff --git a/apps/web/src/server/public-api/hono.ts b/apps/web/src/server/public-api/hono.ts index a23af05..b7905ea 100644 --- a/apps/web/src/server/public-api/hono.ts +++ b/apps/web/src/server/public-api/hono.ts @@ -15,7 +15,7 @@ export function getApp() { version: "1.0.0", title: "Unsend API", }, - servers: [{ url: `${env.APP_URL}/api` }], + servers: [{ url: `${env.NEXTAUTH_URL}/api` }], })); app.openAPIRegistry.registerComponent("securitySchemes", "Bearer", { diff --git a/apps/web/src/server/redis.ts b/apps/web/src/server/redis.ts new file mode 100644 index 0000000..767b8c9 --- /dev/null +++ b/apps/web/src/server/redis.ts @@ -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; +}; diff --git a/apps/web/src/server/service/app-settings-service.ts b/apps/web/src/server/service/app-settings-service.ts deleted file mode 100644 index 5d8ac64..0000000 --- a/apps/web/src/server/service/app-settings-service.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { db } from "../db"; -import { JsonValue } from "@prisma/client/runtime/library"; - -export class AppSettingsService { - private static cache: Record = {}; - - 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 { - const settings = await db.appSetting.findMany(); - settings.forEach((setting) => { - this.cache[setting.key] = setting.value; - }); - } -} diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts index 8a41d84..587bb1d 100644 --- a/apps/web/src/server/service/domain-service.ts +++ b/apps/web/src/server/service/domain-service.ts @@ -3,18 +3,29 @@ import util from "util"; import * as tldts from "tldts"; import * as ses from "~/server/aws/ses"; import { db } from "~/server/db"; +import { SesSettingsService } from "./ses-settings-service"; const dnsResolveTxt = util.promisify(dns.resolveTxt); -export async function createDomain(teamId: number, name: string) { +export async function createDomain( + teamId: number, + name: string, + region: string +) { const domainStr = tldts.getDomain(name); if (!domainStr) { throw new Error("Invalid domain"); } + const setting = await SesSettingsService.getSetting(region); + + if (!setting) { + throw new Error("Ses setting not found"); + } + const subdomain = tldts.getSubdomain(name); - const publicKey = await ses.addDomain(name); + const publicKey = await ses.addDomain(name, region); const domain = await db.domain.create({ data: { @@ -22,6 +33,7 @@ export async function createDomain(teamId: number, name: string) { publicKey, teamId, subdomain, + region, }, }); diff --git a/apps/web/src/server/service/email-queue-service.ts b/apps/web/src/server/service/email-queue-service.ts new file mode 100644 index 0000000..9199ef6 --- /dev/null +++ b/apps/web/src/server/service/email-queue-service.ts @@ -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(); + private static regionWorker = new Map(); + + 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 = 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" }, + }); + } +} diff --git a/apps/web/src/server/service/email-service.ts b/apps/web/src/server/service/email-service.ts index 2d772ed..613dff9 100644 --- a/apps/web/src/server/service/email-service.ts +++ b/apps/web/src/server/service/email-service.ts @@ -1,13 +1,23 @@ import { EmailContent } from "~/types"; import { db } from "../db"; import { UnsendApiError } from "~/server/public-api/api-error"; -import { queueEmail } from "./job-service"; +import { EmailQueueService } from "./email-queue-service"; export async function sendEmail( emailContent: EmailContent & { teamId: number } ) { - const { to, from, subject, text, html, teamId, attachments, replyTo } = - emailContent; + const { + to, + from, + subject, + text, + html, + teamId, + attachments, + replyTo, + cc, + bcc, + } = emailContent; const fromDomain = from.split("@")[1]; @@ -32,10 +42,16 @@ export async function sendEmail( const email = await db.email.create({ data: { - to, + to: Array.isArray(to) ? to : [to], from, subject, - replyTo, + replyTo: replyTo + ? Array.isArray(replyTo) + ? replyTo + : [replyTo] + : undefined, + cc: cc ? (Array.isArray(cc) ? cc : [cc]) : undefined, + bcc: bcc ? (Array.isArray(bcc) ? bcc : [bcc]) : undefined, text, html, teamId, @@ -44,7 +60,24 @@ export async function sendEmail( }, }); - queueEmail(email.id); + try { + await EmailQueueService.queueEmail(email.id, domain.region); + } catch (error: any) { + await db.emailEvent.create({ + data: { + emailId: email.id, + status: "FAILED", + data: { + error: error.toString(), + }, + }, + }); + await db.email.update({ + where: { id: email.id }, + data: { latestStatus: "FAILED" }, + }); + throw error; + } return email; } diff --git a/apps/web/src/server/service/job-service.ts b/apps/web/src/server/service/job-service.ts deleted file mode 100644 index 4c1b5bb..0000000 --- a/apps/web/src/server/service/job-service.ts +++ /dev/null @@ -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 = 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 }, - }); -} diff --git a/apps/web/src/server/service/ses-hook-parser.ts b/apps/web/src/server/service/ses-hook-parser.ts index 42eb4aa..4e6b3f6 100644 --- a/apps/web/src/server/service/ses-hook-parser.ts +++ b/apps/web/src/server/service/ses-hook-parser.ts @@ -2,6 +2,8 @@ import { EmailStatus } from "@prisma/client"; import { SesEvent, SesEventDataKey } from "~/types/aws-types"; import { db } from "../db"; +const STATUS_LIST = Object.values(EmailStatus); + export async function parseSesHook(data: SesEvent) { const mailStatus = getEmailStatus(data); @@ -30,21 +32,12 @@ export async function parseSesHook(data: SesEvent) { id: email.id, }, data: { - latestStatus: mailStatus, + latestStatus: getLatestStatus(email.latestStatus, mailStatus), }, }); - await db.emailEvent.upsert({ - where: { - emailId_status: { - emailId: email.id, - status: mailStatus, - }, - }, - update: { - data: mailData as any, - }, - create: { + await db.emailEvent.create({ + data: { emailId: email.id, status: mailStatus, data: mailData as any, @@ -89,3 +82,12 @@ function getEmailData(data: SesEvent) { return data[eventType.toLowerCase() as SesEventDataKey]; } } + +function getLatestStatus( + currentEmailStatus: EmailStatus, + incomingStatus: EmailStatus +) { + const index = STATUS_LIST.indexOf(currentEmailStatus); + const incomingIndex = STATUS_LIST.indexOf(incomingStatus); + return STATUS_LIST[Math.max(index, incomingIndex)] ?? incomingStatus; +} diff --git a/apps/web/src/server/service/ses-settings-service.ts b/apps/web/src/server/service/ses-settings-service.ts index 2090214..ca72949 100644 --- a/apps/web/src/server/service/ses-settings-service.ts +++ b/apps/web/src/server/service/ses-settings-service.ts @@ -5,8 +5,9 @@ import { customAlphabet } from "nanoid"; import * as sns from "~/server/aws/sns"; import * as ses from "~/server/aws/ses"; import { EventType } from "@aws-sdk/client-sesv2"; +import { EmailQueueService } from "./email-queue-service"; -const nanoid = customAlphabet("1234567890abcdef", 10); +const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 10); const GENERAL_EVENTS: EventType[] = [ "BOUNCE", @@ -21,15 +22,26 @@ const GENERAL_EVENTS: EventType[] = [ export class SesSettingsService { private static cache: Record = {}; + private static topicArns: Array = []; + private static initialized = false; - public static getSetting(region = env.AWS_DEFAULT_REGION): SesSetting | null { + public static async getSetting( + region = env.AWS_DEFAULT_REGION + ): Promise { + await this.checkInitialized(); if (this.cache[region]) { return this.cache[region] as SesSetting; } return null; } - public static getAllSettings() { + public static async getTopicArns() { + await this.checkInitialized(); + return this.topicArns; + } + + public static async getAllSettings() { + await this.checkInitialized(); return Object.values(this.cache); } @@ -46,15 +58,20 @@ export class SesSettingsService { region: string; unsendUrl: string; }) { + await this.checkInitialized(); if (this.cache[region]) { throw new Error(`SesSetting for region ${region} already exists`); } - const unsendUrlValidation = await isValidUnsendUrl(unsendUrl); + const parsedUrl = unsendUrl.endsWith("/") + ? unsendUrl.substring(0, unsendUrl.length - 1) + : unsendUrl; + + const unsendUrlValidation = await isValidUnsendUrl(parsedUrl); if (!unsendUrlValidation.isValid) { throw new Error( - `Unsend URL ${unsendUrl} is not valid, status: ${unsendUrlValidation.code} ${unsendUrlValidation.error}` + `Unsend URL: ${unsendUrl} is not valid, status: ${unsendUrlValidation.code} message:${unsendUrlValidation.error}` ); } @@ -63,28 +80,35 @@ export class SesSettingsService { const setting = await db.sesSetting.create({ data: { region, - callbackUrl: `${unsendUrl}/api/ses_callback`, + callbackUrl: `${parsedUrl}/api/ses_callback`, topic: `${idPrefix}-${region}-unsend`, idPrefix, }, }); await createSettingInAws(setting); + EmailQueueService.initializeQueue(region, setting.sesEmailRateLimit); - this.invalidateCache(); + await this.invalidateCache(); } - public static async init() { + public static async checkInitialized() { + if (!this.initialized) { + await this.invalidateCache(); + this.initialized = true; + } + } + + static async invalidateCache() { + this.cache = {}; const settings = await db.sesSetting.findMany(); settings.forEach((setting) => { this.cache[setting.region] = setting; + if (setting.topicArn) { + this.topicArns.push(setting.topicArn); + } }); } - - static invalidateCache() { - this.cache = {}; - this.init(); - } } async function createSettingInAws(setting: SesSetting) { @@ -95,18 +119,13 @@ async function createSettingInAws(setting: SesSetting) { * Creates a new topic in AWS and subscribes the callback URL to it */ async function registerTopicInAws(setting: SesSetting) { - const topicArn = await sns.createTopic(setting.topic); + const topicArn = await sns.createTopic(setting.topic, setting.region); if (!topicArn) { throw new Error("Failed to create SNS topic"); } - await sns.subscribeEndpoint( - topicArn, - `${setting.callbackUrl}/api/ses_callback` - ); - - return await db.sesSetting.update({ + const _setting = await db.sesSetting.update({ where: { id: setting.id, }, @@ -114,6 +133,17 @@ async function registerTopicInAws(setting: SesSetting) { topicArn, }, }); + + // Invalidate the cache to update the topicArn list + SesSettingsService.invalidateCache(); + + await sns.subscribeEndpoint( + topicArn, + `${setting.callbackUrl}`, + setting.region + ); + + return _setting; } /** @@ -133,28 +163,32 @@ async function registerConfigurationSet(setting: SesSetting) { const generalStatus = await ses.addWebhookConfiguration( configGeneral, setting.topicArn, - GENERAL_EVENTS + GENERAL_EVENTS, + setting.region ); const configClick = `${setting.idPrefix}-${setting.region}-unsend-click`; const clickStatus = await ses.addWebhookConfiguration( configClick, setting.topicArn, - [...GENERAL_EVENTS, "CLICK"] + [...GENERAL_EVENTS, "CLICK"], + setting.region ); const configOpen = `${setting.idPrefix}-${setting.region}-unsend-open`; const openStatus = await ses.addWebhookConfiguration( configOpen, setting.topicArn, - [...GENERAL_EVENTS, "OPEN"] + [...GENERAL_EVENTS, "OPEN"], + setting.region ); const configFull = `${setting.idPrefix}-${setting.region}-unsend-full`; const fullStatus = await ses.addWebhookConfiguration( configFull, setting.topicArn, - [...GENERAL_EVENTS, "CLICK", "OPEN"] + [...GENERAL_EVENTS, "CLICK", "OPEN"], + setting.region ); return await db.sesSetting.update({ @@ -175,10 +209,10 @@ async function registerConfigurationSet(setting: SesSetting) { } async function isValidUnsendUrl(url: string) { + console.log("Checking if URL is valid", url); try { const response = await fetch(`${url}/api/ses_callback`, { - method: "POST", - body: JSON.stringify({ fromUnsend: true }), + method: "GET", }); return { isValid: response.status === 200, @@ -186,6 +220,7 @@ async function isValidUnsendUrl(url: string) { error: response.statusText, }; } catch (e) { + console.log("Error checking if URL is valid", e); return { isValid: false, code: 500, diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts index 361d509..bdead57 100644 --- a/apps/web/src/types/index.ts +++ b/apps/web/src/types/index.ts @@ -1,10 +1,12 @@ export type EmailContent = { - to: string; + to: string | string[]; from: string; subject: string; text?: string; html?: string; - replyTo?: string; + replyTo?: string | string[]; + cc?: string | string[]; + bcc?: string | string[]; attachments?: Array; }; diff --git a/apps/web/src/utils/constants.ts b/apps/web/src/utils/constants.ts deleted file mode 100644 index b4768d3..0000000 --- a/apps/web/src/utils/constants.ts +++ /dev/null @@ -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}`, -}; diff --git a/apps/web/src/utils/ses-utils.ts b/apps/web/src/utils/ses-utils.ts index 41d2c08..c589a5b 100644 --- a/apps/web/src/utils/ses-utils.ts +++ b/apps/web/src/utils/ses-utils.ts @@ -1,18 +1,25 @@ -import { APP_SETTINGS } from "./constants"; +import { SesSettingsService } from "~/server/service/ses-settings-service"; -export function getConfigurationSetName( +export async function getConfigurationSetName( clickTracking: boolean, - openTracking: boolean + openTracking: boolean, + region: string ) { + const setting = await SesSettingsService.getSetting(region); + + if (!setting) { + throw new Error(`No SES setting found for region: ${region}`); + } + if (clickTracking && openTracking) { - return APP_SETTINGS.SES_CONFIGURATION_FULL; + return setting.configFull; } if (clickTracking) { - return APP_SETTINGS.SES_CONFIGURATION_CLICK_TRACKING; + return setting.configClick; } if (openTracking) { - return APP_SETTINGS.SES_CONFIGURATION_OPEN_TRACKING; + return setting.configOpen; } - return APP_SETTINGS.SES_CONFIGURATION_GENERAL; + return setting.configGeneral; } diff --git a/Dockerfile.prod b/docker/Dockerfile similarity index 97% rename from Dockerfile.prod rename to docker/Dockerfile index 117dac3..52f88af 100644 --- a/Dockerfile.prod +++ b/docker/Dockerfile @@ -4,6 +4,7 @@ ENV PATH="$PNPM_HOME:$PATH" ENV SKIP_ENV_VALIDATION="true" ENV DOCKER_OUTPUT 1 ENV NEXT_TELEMETRY_DISABLED 1 +ENV NEXT_PUBLIC_IS_CLOUD="false" RUN corepack enable @@ -14,7 +15,7 @@ RUN apk update WORKDIR /app # Replace with the major version installed in your repository. For example: # RUN yarn global add turbo@^2 -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json start.sh ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ COPY ./apps/web ./apps/web COPY ./packages ./packages RUN pnpm add turbo@^1.12.5 -g @@ -71,6 +72,6 @@ RUN ln -s /app/node_modules/prisma/build/index.js ./node_modules/.bin/prisma # set this so it throws error where starting server ENV SKIP_ENV_VALIDATION="false" -COPY start.sh ./ +COPY ./docker/start.sh ./start.sh CMD ["sh", "start.sh"] \ No newline at end of file diff --git a/docker/build.sh b/docker/build.sh new file mode 100644 index 0000000..ac1ee24 --- /dev/null +++ b/docker/build.sh @@ -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" \ No newline at end of file diff --git a/docker/dev/compose.yml b/docker/dev/compose.yml new file mode 100644 index 0000000..d8277da --- /dev/null +++ b/docker/dev/compose.yml @@ -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: diff --git a/docker-compose.yml b/docker/prod/compose.yml similarity index 65% rename from docker-compose.yml rename to docker/prod/compose.yml index 7306dff..fbb1426 100644 --- a/docker-compose.yml +++ b/docker/prod/compose.yml @@ -3,7 +3,7 @@ name: unsend-prod services: postgres: image: postgres:16 - container_name: postgres + container_name: unsend-db-prod restart: always environment: - POSTGRES_USER=${POSTGRES_USER:?err} @@ -14,19 +14,23 @@ services: interval: 10s timeout: 5s retries: 5 + # ports: + # - "5432:5432" volumes: - database:/var/lib/postgresql/data - # You don't need to expose this port to the host since, docker compose creates an internal network - # through which both of these containers could talk to each other using their container_name as hostname - # But if you want to connect this to a querying tool to debug you can definitely uncomment this + redis: + image: redis:7 + container_name: unsend-redis-prod + restart: always # ports: - # - "5432:5432" + # - "6379:6379" + volumes: + - cache:/data + command: ["redis-server", "--maxmemory-policy", "noeviction"] unsend: - build: - dockerfile: Dockerfile - image: unsend + image: unsend/unsend:latest container_name: unsend restart: always ports: @@ -41,16 +45,15 @@ services: - AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:?err} - GITHUB_ID=${GITHUB_ID:?err} - GITHUB_SECRET=${GITHUB_SECRET:?err} - - APP_URL=${APP_URL:-${NEXTAUTH_URL}} - - SNS_TOPIC=${SNS_TOPIC:?err} + - REDIS_URL=${REDIS_URL:?err} - NEXT_PUBLIC_IS_CLOUD=${NEXT_PUBLIC_IS_CLOUD:-false} - - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} - - SES_QUEUE_LIMIT=${SES_QUEUE_LIMIT:-1} - API_RATE_LIMIT=${API_RATE_LIMIT:-1} depends_on: postgres: condition: service_healthy + redis: + condition: service_started volumes: database: + cache: diff --git a/start.sh b/docker/start.sh similarity index 100% rename from start.sh rename to docker/start.sh diff --git a/package.json b/package.json index a1d3a2c..e8d59e7 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,14 @@ "db:push": "pnpm db db:push", "db:migrate-dev": "pnpm db db:migrate-dev", "db:migrate-deploy": "pnpm db db:migrate-deploy", + "db:migrate-reset": "pnpm db db:migrate-reset", "db:studio": "pnpm db db:studio", "db": "pnpm load-env -- pnpm --filter=web", - "load-env": "dotenv -e .env" + "load-env": "dotenv -e .env", + "d": "pnpm dx && pnpm dev", + "dx": "pnpm i && pnpm dx:up && pnpm db:migrate-dev", + "dx:up": "docker compose -f docker/dev/compose.yml up -d", + "dx:down": "docker compose -f docker/dev/compose.yml down" }, "devDependencies": { "@unsend/eslint-config": "workspace:*", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 6b02f15..b12d7d7 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -9,7 +9,8 @@ "test": "echo \"Error: no test specified\" && exit 1", "lint": "eslint . --max-warnings 0", "build": "rm -rf dist && tsup index.ts --format esm,cjs --dts", - "publish-sdk": "pnpm run build && pnpm publish" + "publish-sdk": "pnpm run build && pnpm publish", + "openapi-typegen": "openapi-typescript ../../apps/docs/api-reference/openapi.json -o types/schema.d.ts" }, "keywords": [], "author": "", diff --git a/packages/sdk/types/schema.d.ts b/packages/sdk/types/schema.d.ts index 91e7d98..05851e1 100644 --- a/packages/sdk/types/schema.d.ts +++ b/packages/sdk/types/schema.d.ts @@ -60,7 +60,10 @@ export interface paths { "application/json": { id: string; teamId: number; - to: string; + to: string | string[]; + replyTo?: string | string[]; + cc?: string | string[]; + bcc?: string | string[]; from: string; subject: string; html: string | null; @@ -85,12 +88,13 @@ export interface paths { requestBody: { content: { "application/json": { - /** Format: email */ - to: string; + to: string | string[]; /** Format: email */ from: string; subject: string; - replyTo?: string; + replyTo?: string | string[]; + cc?: string | string[]; + bcc?: string | string[]; text?: string; html?: string; attachments?: { diff --git a/packages/ui/src/code.tsx b/packages/ui/src/code.tsx index 5922087..0b472b4 100644 --- a/packages/ui/src/code.tsx +++ b/packages/ui/src/code.tsx @@ -11,13 +11,16 @@ import { ClipboardCopy, Check } from "lucide-react"; import { useState } from "react"; import { cn } from "../lib/utils"; -type Language = "js" | "ruby" | "php" | "python" | "curl"; +export type Language = "js" | "ruby" | "php" | "python" | "curl"; + +export type CodeBlock = { + language: Language; + title?: string; + code: string; +}; type CodeProps = { - codeBlocks: { - language: Language; - code: string; - }[]; + codeBlocks: CodeBlock[]; codeClassName?: string; }; @@ -57,7 +60,7 @@ export const Code: React.FC = ({ codeBlocks, codeClassName }) => { value={block.language} className="data-[state=active]:bg-accent py-0.5 px-4 " > - {block.language} + {block.title || block.language} ))}

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