Improve Self host setup (#30)
* Add self host setup * Improve blunders * Move to bull mq * More changes * Add example code for sending test emails
This commit is contained in:
34
.dockerignore
Normal file
34
.dockerignore
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# turbo
|
||||||
|
.turbo
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
30
.env.selfhost.example
Normal file
30
.env.selfhost.example
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Redis container name
|
||||||
|
REDIS_URL="redis://redis:6379"
|
||||||
|
|
||||||
|
# Postgres
|
||||||
|
POSTGRES_USER="postgres"
|
||||||
|
POSTGRES_PASSWORD="postgres"
|
||||||
|
POSTGRES_DB="unsend"
|
||||||
|
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/unsend"
|
||||||
|
|
||||||
|
# NextAuth
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
NEXTAUTH_SECRET=
|
||||||
|
|
||||||
|
# Github login
|
||||||
|
GITHUB_ID="<your-github-client-id>"
|
||||||
|
GITHUB_SECRET="<your-github-client-secret>"
|
||||||
|
|
||||||
|
# AWS details
|
||||||
|
AWS_DEFAULT_REGION="us-east-1"
|
||||||
|
AWS_SECRET_KEY="<your-aws-secret-key>"
|
||||||
|
AWS_ACCESS_KEY="<your-aws-access-key>"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
DOCKER_OUTPUT=1
|
||||||
|
NEXT_PUBLIC_IS_CLOUD=false
|
||||||
|
API_RATE_LIMIT=1
|
||||||
|
|
||||||
|
# used to send important error notification
|
||||||
|
DISCORD_WEBHOOK_URL=""
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
|||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
.env.selfhost
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
coverage
|
coverage
|
||||||
|
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
"url": "https://app.unsend.dev/api"
|
"url": "https://test.ossapps.dev/api"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"components": {
|
"components": {
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 3,
|
"minLength": 3,
|
||||||
"example": "1212121"
|
"example": "cuiwqdj74rygf74"
|
||||||
},
|
},
|
||||||
"required": true,
|
"required": true,
|
||||||
"name": "emailId",
|
"name": "emailId",
|
||||||
@@ -133,7 +133,56 @@
|
|||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
"to": {
|
"to": {
|
||||||
"type": "string"
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"replyTo": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"cc": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bcc": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"from": {
|
"from": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -222,8 +271,19 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"to": {
|
"to": {
|
||||||
"type": "string",
|
"anyOf": [
|
||||||
"format": "email"
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "email"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "email"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"from": {
|
"from": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -233,7 +293,49 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"replyTo": {
|
"replyTo": {
|
||||||
"type": "string"
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "email"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "email"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"cc": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "email"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "email"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bcc": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "email"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "email"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"text": {
|
"text": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
@@ -7,6 +7,11 @@ await import("./src/env.js");
|
|||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
output: process.env.DOCKER_OUTPUT ? "standalone" : undefined,
|
output: process.env.DOCKER_OUTPUT ? "standalone" : undefined,
|
||||||
|
experimental: {
|
||||||
|
instrumentationHook: true,
|
||||||
|
esmExternals: "loose",
|
||||||
|
serverComponentsExternalPackages: ["bullmq"],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
@@ -13,7 +13,8 @@
|
|||||||
"db:push": "prisma db push --skip-generate",
|
"db:push": "prisma db push --skip-generate",
|
||||||
"db:migrate-dev": "prisma migrate dev",
|
"db:migrate-dev": "prisma migrate dev",
|
||||||
"db:migrate-deploy": "prisma migrate deploy",
|
"db:migrate-deploy": "prisma migrate deploy",
|
||||||
"db:studio": "prisma studio"
|
"db:studio": "prisma studio",
|
||||||
|
"db:migrate-reset": "prisma migrate reset"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^1.4.0",
|
"@auth/prisma-adapter": "^1.4.0",
|
||||||
@@ -32,9 +33,11 @@
|
|||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
"@trpc/server": "next",
|
"@trpc/server": "next",
|
||||||
"@unsend/ui": "workspace:*",
|
"@unsend/ui": "workspace:*",
|
||||||
|
"bullmq": "^5.8.2",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"hono": "^4.2.2",
|
"hono": "^4.2.2",
|
||||||
"install": "^0.13.0",
|
"install": "^0.13.0",
|
||||||
|
"ioredis": "^5.4.1",
|
||||||
"lucide-react": "^0.359.0",
|
"lucide-react": "^0.359.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
@@ -51,6 +54,7 @@
|
|||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
"tldts": "^6.1.16",
|
"tldts": "^6.1.16",
|
||||||
|
"ua-parser-js": "^1.0.38",
|
||||||
"unsend": "workspace:*",
|
"unsend": "workspace:*",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
@@ -61,6 +65,7 @@
|
|||||||
"@types/node": "^20.11.20",
|
"@types/node": "^20.11.20",
|
||||||
"@types/react": "^18.2.57",
|
"@types/react": "^18.2.57",
|
||||||
"@types/react-dom": "^18.2.19",
|
"@types/react-dom": "^18.2.19",
|
||||||
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
||||||
"@typescript-eslint/parser": "^7.1.1",
|
"@typescript-eslint/parser": "^7.1.1",
|
||||||
"@unsend/eslint-config": "workspace:*",
|
"@unsend/eslint-config": "workspace:*",
|
||||||
|
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "User" ADD COLUMN "isBetaUser" BOOLEAN NOT NULL DEFAULT false;
|
|
@@ -1,5 +0,0 @@
|
|||||||
-- AlterEnum
|
|
||||||
ALTER TYPE "EmailStatus" ADD VALUE 'QUEUED';
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Email" ADD COLUMN "attachments" TEXT;
|
|
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Email" ALTER COLUMN "latestStatus" SET DEFAULT 'QUEUED';
|
|
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Email" ADD COLUMN "replyTo" TEXT;
|
|
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Domain" ADD COLUMN "isVerifying" BOOLEAN NOT NULL DEFAULT false;
|
|
@@ -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");
|
|
@@ -8,7 +8,7 @@ CREATE TYPE "DomainStatus" AS ENUM ('NOT_STARTED', 'PENDING', 'SUCCESS', 'FAILED
|
|||||||
CREATE TYPE "ApiPermission" AS ENUM ('FULL', 'SENDING');
|
CREATE TYPE "ApiPermission" AS ENUM ('FULL', 'SENDING');
|
||||||
|
|
||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
CREATE TYPE "EmailStatus" AS ENUM ('SENT', 'OPENED', 'CLICKED', 'BOUNCED', 'COMPLAINED', 'DELIVERED', 'REJECTED', 'RENDERING_FAILURE', 'DELIVERY_DELAYED');
|
CREATE TYPE "EmailStatus" AS ENUM ('QUEUED', 'SENT', 'OPENED', 'CLICKED', 'BOUNCED', 'COMPLAINED', 'DELIVERED', 'REJECTED', 'RENDERING_FAILURE', 'DELIVERY_DELAYED', 'FAILED');
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "AppSetting" (
|
CREATE TABLE "AppSetting" (
|
||||||
@@ -18,6 +18,30 @@ CREATE TABLE "AppSetting" (
|
|||||||
CONSTRAINT "AppSetting_pkey" PRIMARY KEY ("key")
|
CONSTRAINT "AppSetting_pkey" PRIMARY KEY ("key")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SesSetting" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"region" TEXT NOT NULL,
|
||||||
|
"idPrefix" TEXT NOT NULL,
|
||||||
|
"topic" TEXT NOT NULL,
|
||||||
|
"topicArn" TEXT,
|
||||||
|
"callbackUrl" TEXT NOT NULL,
|
||||||
|
"callbackSuccess" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"configGeneral" TEXT,
|
||||||
|
"configGeneralSuccess" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"configClick" TEXT,
|
||||||
|
"configClickSuccess" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"configOpen" TEXT,
|
||||||
|
"configOpenSuccess" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"configFull" TEXT,
|
||||||
|
"configFullSuccess" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"sesEmailRateLimit" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "SesSetting_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "Account" (
|
CREATE TABLE "Account" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
@@ -61,6 +85,7 @@ CREATE TABLE "User" (
|
|||||||
"email" TEXT,
|
"email" TEXT,
|
||||||
"emailVerified" TIMESTAMP(3),
|
"emailVerified" TIMESTAMP(3),
|
||||||
"image" TEXT,
|
"image" TEXT,
|
||||||
|
"isBetaUser" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
@@ -97,6 +122,7 @@ CREATE TABLE "Domain" (
|
|||||||
"dmarcAdded" BOOLEAN NOT NULL DEFAULT false,
|
"dmarcAdded" BOOLEAN NOT NULL DEFAULT false,
|
||||||
"errorMessage" TEXT,
|
"errorMessage" TEXT,
|
||||||
"subdomain" TEXT,
|
"subdomain" TEXT,
|
||||||
|
"isVerifying" BOOLEAN NOT NULL DEFAULT false,
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
@@ -122,28 +148,38 @@ CREATE TABLE "ApiKey" (
|
|||||||
CREATE TABLE "Email" (
|
CREATE TABLE "Email" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"sesEmailId" TEXT,
|
"sesEmailId" TEXT,
|
||||||
"to" TEXT NOT NULL,
|
|
||||||
"from" TEXT NOT NULL,
|
"from" TEXT NOT NULL,
|
||||||
|
"to" TEXT[],
|
||||||
|
"replyTo" TEXT[],
|
||||||
|
"cc" TEXT[],
|
||||||
|
"bcc" TEXT[],
|
||||||
"subject" TEXT NOT NULL,
|
"subject" TEXT NOT NULL,
|
||||||
"text" TEXT,
|
"text" TEXT,
|
||||||
"html" TEXT,
|
"html" TEXT,
|
||||||
"latestStatus" "EmailStatus" NOT NULL DEFAULT 'SENT',
|
"latestStatus" "EmailStatus" NOT NULL DEFAULT 'QUEUED',
|
||||||
"teamId" INTEGER NOT NULL,
|
"teamId" INTEGER NOT NULL,
|
||||||
"domainId" INTEGER,
|
"domainId" INTEGER,
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"attachments" TEXT,
|
||||||
|
|
||||||
CONSTRAINT "Email_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "Email_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "EmailEvent" (
|
CREATE TABLE "EmailEvent" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
"emailId" TEXT NOT NULL,
|
"emailId" TEXT NOT NULL,
|
||||||
"status" "EmailStatus" NOT NULL,
|
"status" "EmailStatus" NOT NULL,
|
||||||
"data" JSONB,
|
"data" JSONB,
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "EmailEvent_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "SesSetting_region_key" ON "SesSetting"("region");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
|
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
|
||||||
|
|
||||||
@@ -171,9 +207,6 @@ CREATE UNIQUE INDEX "ApiKey_tokenHash_key" ON "ApiKey"("tokenHash");
|
|||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "Email_sesEmailId_key" ON "Email"("sesEmailId");
|
CREATE UNIQUE INDEX "Email_sesEmailId_key" ON "Email"("sesEmailId");
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "EmailEvent_emailId_status_key" ON "EmailEvent"("emailId", "status");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@@ -35,6 +35,7 @@ model SesSetting {
|
|||||||
configOpenSuccess Boolean @default(false)
|
configOpenSuccess Boolean @default(false)
|
||||||
configFull String?
|
configFull String?
|
||||||
configFullSuccess Boolean @default(false)
|
configFullSuccess Boolean @default(false)
|
||||||
|
sesEmailRateLimit Int @default(1)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
@@ -162,23 +163,26 @@ model ApiKey {
|
|||||||
enum EmailStatus {
|
enum EmailStatus {
|
||||||
QUEUED
|
QUEUED
|
||||||
SENT
|
SENT
|
||||||
|
BOUNCED
|
||||||
|
DELIVERED
|
||||||
OPENED
|
OPENED
|
||||||
CLICKED
|
CLICKED
|
||||||
BOUNCED
|
|
||||||
COMPLAINED
|
COMPLAINED
|
||||||
DELIVERED
|
|
||||||
REJECTED
|
REJECTED
|
||||||
RENDERING_FAILURE
|
RENDERING_FAILURE
|
||||||
DELIVERY_DELAYED
|
DELIVERY_DELAYED
|
||||||
|
FAILED
|
||||||
}
|
}
|
||||||
|
|
||||||
model Email {
|
model Email {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
sesEmailId String? @unique
|
sesEmailId String? @unique
|
||||||
to String
|
|
||||||
from String
|
from String
|
||||||
|
to String[]
|
||||||
|
replyTo String[]
|
||||||
|
cc String[]
|
||||||
|
bcc String[]
|
||||||
subject String
|
subject String
|
||||||
replyTo String?
|
|
||||||
text String?
|
text String?
|
||||||
html String?
|
html String?
|
||||||
latestStatus EmailStatus @default(QUEUED)
|
latestStatus EmailStatus @default(QUEUED)
|
||||||
@@ -192,11 +196,10 @@ model Email {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model EmailEvent {
|
model EmailEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
emailId String
|
emailId String
|
||||||
status EmailStatus
|
status EmailStatus
|
||||||
data Json?
|
data Json?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
email Email @relation(fields: [emailId], references: [id], onDelete: Cascade)
|
email Email @relation(fields: [emailId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([emailId, status])
|
|
||||||
}
|
}
|
||||||
|
40
apps/web/src/app/(dashboard)/admin/add-ses-configuration.tsx
Normal file
40
apps/web/src/app/(dashboard)/admin/add-ses-configuration.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@unsend/ui/src/dialog";
|
||||||
|
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AddSesSettingsForm } from "~/components/settings/AddSesSettings";
|
||||||
|
|
||||||
|
export default function AddSesConfiguration() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add SES configuration
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add a new SES configuration</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
<AddSesSettingsForm onSuccess={() => setOpen(false)} />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
19
apps/web/src/app/(dashboard)/admin/page.tsx
Normal file
19
apps/web/src/app/(dashboard)/admin/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import AddSesConfiguration from "./add-ses-configuration";
|
||||||
|
import SesConfigurations from "./ses-configurations";
|
||||||
|
|
||||||
|
export default function ApiKeysPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="font-bold text-lg">Admin</h1>
|
||||||
|
<AddSesConfiguration />
|
||||||
|
</div>
|
||||||
|
<div className="mt-10">
|
||||||
|
<p className="font-semibold mb-4">SES Configurations</p>
|
||||||
|
<SesConfigurations />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
65
apps/web/src/app/(dashboard)/admin/ses-configurations.tsx
Normal file
65
apps/web/src/app/(dashboard)/admin/ses-configurations.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@unsend/ui/src/table";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import Spinner from "@unsend/ui/src/spinner";
|
||||||
|
|
||||||
|
export default function SesConfigurations() {
|
||||||
|
const sesSettingsQuery = api.admin.getSesSettings.useQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="">
|
||||||
|
<div className="border rounded-xl">
|
||||||
|
<Table className="">
|
||||||
|
<TableHeader className="">
|
||||||
|
<TableRow className=" bg-muted/30">
|
||||||
|
<TableHead className="rounded-tl-xl">Region</TableHead>
|
||||||
|
<TableHead>Callback URL</TableHead>
|
||||||
|
<TableHead>Callback status</TableHead>
|
||||||
|
<TableHead>Created at</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sesSettingsQuery.isLoading ? (
|
||||||
|
<TableRow className="h-32">
|
||||||
|
<TableCell colSpan={6} className="text-center py-4">
|
||||||
|
<Spinner
|
||||||
|
className="w-6 h-6 mx-auto"
|
||||||
|
innerSvgClass="stroke-primary"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : sesSettingsQuery.data?.length === 0 ? (
|
||||||
|
<TableRow className="h-32">
|
||||||
|
<TableCell colSpan={6} className="text-center py-4">
|
||||||
|
<p>No SES configurations added</p>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
sesSettingsQuery.data?.map((sesSetting) => (
|
||||||
|
<TableRow key={sesSetting.id}>
|
||||||
|
<TableCell>{sesSetting.region}</TableCell>
|
||||||
|
<TableCell>{sesSetting.callbackUrl}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{sesSetting.callbackSuccess ? "Success" : "Failed"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatDistanceToNow(sesSetting.createdAt)} ago
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
192
apps/web/src/app/(dashboard)/dasboard-layout.tsx
Normal file
192
apps/web/src/app/(dashboard)/dasboard-layout.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { LogoutButton, NavButton } from "./nav-button";
|
||||||
|
import {
|
||||||
|
BookOpenText,
|
||||||
|
BookUser,
|
||||||
|
CircleUser,
|
||||||
|
Code,
|
||||||
|
Globe,
|
||||||
|
Home,
|
||||||
|
LayoutDashboard,
|
||||||
|
LineChart,
|
||||||
|
Mail,
|
||||||
|
Menu,
|
||||||
|
Package,
|
||||||
|
Package2,
|
||||||
|
Server,
|
||||||
|
ShoppingCart,
|
||||||
|
Users,
|
||||||
|
Volume2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { Sheet, SheetContent, SheetTrigger } from "@unsend/ui/src/sheet";
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@unsend/ui/src/dropdown-menu";
|
||||||
|
|
||||||
|
export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen w-full h-full">
|
||||||
|
<div className="hidden bg-muted/20 md:block md:w-[280px]">
|
||||||
|
<div className="flex h-full max-h-screen flex-col gap-2">
|
||||||
|
<div className="flex h-14 gap-4 items-center px-4 lg:h-[60px] lg:px-6">
|
||||||
|
<Link href="/" className="flex items-center gap-2 font-semibold">
|
||||||
|
<span className=" text-lg">Unsend</span>
|
||||||
|
</Link>
|
||||||
|
<span className="text-[10px] text-muted-foreground bg-muted p-0.5 px-2 rounded-full">
|
||||||
|
Early access
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 h-full">
|
||||||
|
<nav className=" flex-1 h-full flex-col justify-between items-center px-2 text-sm font-medium lg:px-4">
|
||||||
|
<div>
|
||||||
|
<NavButton href="/dashboard">
|
||||||
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
|
Dashboard
|
||||||
|
</NavButton>
|
||||||
|
|
||||||
|
<NavButton href="/emails">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
Emails
|
||||||
|
</NavButton>
|
||||||
|
|
||||||
|
<NavButton href="/domains">
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
Domains
|
||||||
|
</NavButton>
|
||||||
|
|
||||||
|
<NavButton href="/contacts" comingSoon>
|
||||||
|
<BookUser className="h-4 w-4" />
|
||||||
|
Contacts
|
||||||
|
</NavButton>
|
||||||
|
|
||||||
|
<NavButton href="/contacts" comingSoon>
|
||||||
|
<Volume2 className="h-4 w-4" />
|
||||||
|
Marketing
|
||||||
|
</NavButton>
|
||||||
|
|
||||||
|
<NavButton href="/api-keys">
|
||||||
|
<Code className="h-4 w-4" />
|
||||||
|
Developer settings
|
||||||
|
</NavButton>
|
||||||
|
{!env.NEXT_PUBLIC_IS_CLOUD || session?.user.isAdmin ? (
|
||||||
|
<NavButton href="/admin">
|
||||||
|
<Server className="h-4 w-4" />
|
||||||
|
Admin
|
||||||
|
</NavButton>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className=" absolute bottom-10 p-4 flex flex-col gap-2">
|
||||||
|
<Link
|
||||||
|
href="https://docs.unsend.dev"
|
||||||
|
target="_blank"
|
||||||
|
className="flex gap-2 items-center hover:text-primary text-muted-foreground"
|
||||||
|
>
|
||||||
|
<BookOpenText className="h-4 w-4" />
|
||||||
|
<span className="">Docs</span>
|
||||||
|
</Link>
|
||||||
|
<LogoutButton />
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div className="mt-auto p-4"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<header className="flex h-14 items-center gap-4 md:hidden bg-muted/20 px-4 lg:h-[60px] lg:px-6">
|
||||||
|
<Sheet>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 md:hidden"
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Toggle navigation menu</span>
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="left" className="flex flex-col">
|
||||||
|
<nav className="grid gap-2 text-lg font-medium">
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
className="flex items-center gap-2 text-lg font-semibold"
|
||||||
|
>
|
||||||
|
<Package2 className="h-6 w-6" />
|
||||||
|
<span className="sr-only">Acme Inc</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Home className="h-5 w-5" />
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl bg-muted px-3 py-2 text-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ShoppingCart className="h-5 w-5" />
|
||||||
|
Orders
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Package className="h-5 w-5" />
|
||||||
|
Products
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
Customers
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<LineChart className="h-5 w-5" />
|
||||||
|
Analytics
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
<div className="mt-auto"></div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="secondary" size="icon" className="rounded-full">
|
||||||
|
<CircleUser className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Toggle user menu</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>Settings</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>Support</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>Logout</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 overflow-y-auto h-full">
|
||||||
|
<div className="flex flex-col gap-4 p-4 w-full lg:max-w-6xl mx-auto lg:gap-6 lg:p-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -26,6 +26,7 @@ import DeleteDomain from "./delete-domain";
|
|||||||
import SendTestMail from "./send-test-mail";
|
import SendTestMail from "./send-test-mail";
|
||||||
import { Button } from "@unsend/ui/src/button";
|
import { Button } from "@unsend/ui/src/button";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { toast } from "@unsend/ui/src/toaster";
|
||||||
|
|
||||||
export default function DomainItemPage({
|
export default function DomainItemPage({
|
||||||
params,
|
params,
|
||||||
@@ -245,7 +246,8 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
|
|||||||
{ id: domain.id, clickTracking: !clickTracking },
|
{ id: domain.id, clickTracking: !clickTracking },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.domain.domains.invalidate();
|
utils.domain.invalidate();
|
||||||
|
toast.success("Click tracking updated");
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -257,7 +259,8 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
|
|||||||
{ id: domain.id, openTracking: !openTracking },
|
{ id: domain.id, openTracking: !openTracking },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.domain.domains.invalidate();
|
utils.domain.invalidate();
|
||||||
|
toast.success("Open tracking updated");
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@@ -14,6 +14,8 @@ import { Domain } from "@prisma/client";
|
|||||||
import { toast } from "@unsend/ui/src/toaster";
|
import { toast } from "@unsend/ui/src/toaster";
|
||||||
import { SendHorizonal } from "lucide-react";
|
import { SendHorizonal } from "lucide-react";
|
||||||
import { Code } from "@unsend/ui/src/code";
|
import { Code } from "@unsend/ui/src/code";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { getSendTestEmailCode } from "~/lib/constants/example-codes";
|
||||||
|
|
||||||
const jsCode = `const requestOptions = {
|
const jsCode = `const requestOptions = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -112,6 +114,8 @@ export const SendTestMail: React.FC<{ domain: Domain }> = ({ domain }) => {
|
|||||||
const sendTestEmailFromDomainMutation =
|
const sendTestEmailFromDomainMutation =
|
||||||
api.domain.sendTestEmailFromDomain.useMutation();
|
api.domain.sendTestEmailFromDomain.useMutation();
|
||||||
|
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
function handleSendTestEmail() {
|
function handleSendTestEmail() {
|
||||||
@@ -145,12 +149,14 @@ export const SendTestMail: React.FC<{ domain: Domain }> = ({ domain }) => {
|
|||||||
<DialogTitle>Send test email</DialogTitle>
|
<DialogTitle>Send test email</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Code
|
<Code
|
||||||
codeBlocks={[
|
codeBlocks={getSendTestEmailCode({
|
||||||
{ language: "js", code: jsCode },
|
from: `hello@${domain.name}`,
|
||||||
{ language: "ruby", code: rubyCode },
|
to: session?.user?.email || "",
|
||||||
{ language: "php", code: phpCode },
|
subject: "Unsend test email",
|
||||||
{ language: "python", code: pythonCode },
|
body: "hello,\\n\\nUnsend is the best open source sending platform",
|
||||||
]}
|
bodyHtml:
|
||||||
|
"<p>hello,</p><p>Unsend is the best open source sending platform<p><p>check out <a href='https://unsend.dev'>unsend.dev</a>",
|
||||||
|
})}
|
||||||
codeClassName="max-w-[38rem] h-[20rem]"
|
codeClassName="max-w-[38rem] h-[20rem]"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end w-full">
|
<div className="flex justify-end w-full">
|
||||||
|
@@ -27,17 +27,37 @@ import * as tldts from "tldts";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@unsend/ui/src/select";
|
||||||
|
import { toast } from "@unsend/ui/src/toaster";
|
||||||
|
|
||||||
const domainSchema = z.object({
|
const domainSchema = z.object({
|
||||||
domain: z.string({ required_error: "Domain is required" }),
|
region: z.string({ required_error: "Region is required" }).min(1, {
|
||||||
|
message: "Region is required",
|
||||||
|
}),
|
||||||
|
domain: z.string({ required_error: "Domain is required" }).min(1, {
|
||||||
|
message: "Domain is required",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function AddDomain() {
|
export default function AddDomain() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const regionQuery = api.domain.getAvailableRegions.useQuery();
|
||||||
|
|
||||||
const addDomainMutation = api.domain.createDomain.useMutation();
|
const addDomainMutation = api.domain.createDomain.useMutation();
|
||||||
|
|
||||||
const domainForm = useForm<z.infer<typeof domainSchema>>({
|
const domainForm = useForm<z.infer<typeof domainSchema>>({
|
||||||
resolver: zodResolver(domainSchema),
|
resolver: zodResolver(domainSchema),
|
||||||
|
defaultValues: {
|
||||||
|
region: "",
|
||||||
|
domain: "",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
@@ -56,6 +76,7 @@ export default function AddDomain() {
|
|||||||
addDomainMutation.mutate(
|
addDomainMutation.mutate(
|
||||||
{
|
{
|
||||||
name: values.domain,
|
name: values.domain,
|
||||||
|
region: values.region,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: async (data) => {
|
onSuccess: async (data) => {
|
||||||
@@ -63,6 +84,9 @@ export default function AddDomain() {
|
|||||||
await router.push(`/domains/${data.id}`);
|
await router.push(`/domains/${data.id}`);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
},
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -108,6 +132,41 @@ export default function AddDomain() {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={domainForm.control}
|
||||||
|
name="region"
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Region</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value}
|
||||||
|
disabled={regionQuery.isLoading}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select region" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{regionQuery.data?.map((region) => (
|
||||||
|
<SelectItem value={region} key={region}>
|
||||||
|
{region}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{formState.errors.region ? (
|
||||||
|
<FormMessage />
|
||||||
|
) : (
|
||||||
|
<FormDescription>
|
||||||
|
Select the region from where the email is sent{" "}
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
|
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
|
||||||
|
@@ -95,9 +95,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Region</p>
|
<p className="text-sm text-muted-foreground">Region</p>
|
||||||
|
|
||||||
<p className="text-sm flex items-center gap-2">
|
<p className="text-sm flex items-center gap-2">{domain.region}</p>
|
||||||
<span className="text-2xl">🇺🇸</span> {domain.region}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
|
@@ -1,14 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { UAParser } from "ua-parser-js";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { Separator } from "@unsend/ui/src/separator";
|
import { Separator } from "@unsend/ui/src/separator";
|
||||||
import { EmailStatusBadge, EmailStatusIcon } from "./email-status-badge";
|
import { EmailStatusBadge, EmailStatusIcon } from "./email-status-badge";
|
||||||
import { formatDate } from "date-fns";
|
import { formatDate } from "date-fns";
|
||||||
import { EmailStatus } from "@prisma/client";
|
import { EmailStatus } from "@prisma/client";
|
||||||
import { JsonValue } from "@prisma/client/runtime/library";
|
import { JsonValue } from "@prisma/client/runtime/library";
|
||||||
import { SesBounce, SesDeliveryDelay } from "~/types/aws-types";
|
import {
|
||||||
|
SesBounce,
|
||||||
|
SesClick,
|
||||||
|
SesComplaint,
|
||||||
|
SesDeliveryDelay,
|
||||||
|
SesOpen,
|
||||||
|
} from "~/types/aws-types";
|
||||||
import {
|
import {
|
||||||
BOUNCE_ERROR_MESSAGES,
|
BOUNCE_ERROR_MESSAGES,
|
||||||
|
COMPLAINT_ERROR_MESSAGES,
|
||||||
DELIVERY_DELAY_ERRORS,
|
DELIVERY_DELAY_ERRORS,
|
||||||
} from "~/lib/constants/ses-errors";
|
} from "~/lib/constants/ses-errors";
|
||||||
|
|
||||||
@@ -39,7 +47,7 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
|
|||||||
<span className="w-[65px] text-muted-foreground ">Subject</span>
|
<span className="w-[65px] text-muted-foreground ">Subject</span>
|
||||||
<span>{emailQuery.data?.subject}</span>
|
<span>{emailQuery.data?.subject}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className=" dark:bg-slate-200 h-[300px] overflow-auto text-black rounded">
|
<div className=" dark:bg-slate-200 h-[250px] overflow-auto text-black rounded">
|
||||||
<div
|
<div
|
||||||
className="px-4 py-4 overflow-auto"
|
className="px-4 py-4 overflow-auto"
|
||||||
dangerouslySetInnerHTML={{ __html: emailQuery.data?.html ?? "" }}
|
dangerouslySetInnerHTML={{ __html: emailQuery.data?.html ?? "" }}
|
||||||
@@ -47,17 +55,20 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className=" border rounded-lg w-full ">
|
<div className=" border rounded-lg w-full ">
|
||||||
<div className=" p-4 flex flex-col gap-8">
|
<div className=" p-4 flex flex-col gap-8 w-full">
|
||||||
<div className="font-medium">Events History</div>
|
<div className="font-medium">Events History</div>
|
||||||
<div className="flex items-stretch px-4">
|
<div className="flex items-stretch px-4 w-full">
|
||||||
<div className="border-r border-dashed" />
|
<div className="border-r border-dashed" />
|
||||||
<div className="flex flex-col gap-12">
|
<div className="flex flex-col gap-12 w-full">
|
||||||
{emailQuery.data?.emailEvents.map((evt) => (
|
{emailQuery.data?.emailEvents.map((evt) => (
|
||||||
<div key={evt.status} className="flex gap-5 items-start">
|
<div
|
||||||
|
key={evt.status}
|
||||||
|
className="flex gap-5 items-start w-full"
|
||||||
|
>
|
||||||
<div className=" -ml-2.5">
|
<div className=" -ml-2.5">
|
||||||
<EmailStatusIcon status={evt.status} />
|
<EmailStatusIcon status={evt.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="-mt-[0.125rem]">
|
<div className="-mt-[0.125rem] w-full">
|
||||||
<div className=" capitalize font-medium">
|
<div className=" capitalize font-medium">
|
||||||
<EmailStatusBadge status={evt.status} />
|
<EmailStatusBadge status={evt.status} />
|
||||||
</div>
|
</div>
|
||||||
@@ -104,26 +115,88 @@ const EmailStatusText = ({
|
|||||||
_errorData.bounceType;
|
_errorData.bounceType;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<p>{getErrorMessage(_errorData)}</p>
|
<p>{getErrorMessage(_errorData)}</p>
|
||||||
<div className="flex gap-2 ">
|
<div className="rounded-xl p-4 bg-muted/20 flex flex-col gap-4">
|
||||||
<div className="w-1/2">
|
<div className="flex gap-2 w-full">
|
||||||
<p className="text-sm text-muted-foreground">Type </p>
|
<div className="w-1/2">
|
||||||
<p>{_errorData.bounceType}</p>
|
<p className="text-sm text-muted-foreground">Type</p>
|
||||||
|
<p>{_errorData.bounceType}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Sub Type</p>
|
||||||
|
<p>{_errorData.bounceSubType}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Sub Type </p>
|
<p className="text-sm text-muted-foreground">SMTP response</p>
|
||||||
<p>{_errorData.bounceSubType}</p>
|
<p>{_errorData.bouncedRecipients[0]?.diagnosticCode}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">SMTP response</p>
|
);
|
||||||
<p>{_errorData.bouncedRecipients[0]?.diagnosticCode}</p>
|
} else if (status === "FAILED") {
|
||||||
</div>
|
const _errorData = data as unknown as { error: string };
|
||||||
|
return <div>{_errorData.error}</div>;
|
||||||
|
} else if (status === "OPENED") {
|
||||||
|
const _data = data as unknown as SesOpen;
|
||||||
|
const userAgent = getUserAgent(_data.userAgent);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full rounded-xl p-4 bg-muted/20 mt-4">
|
||||||
|
<div className="flex w-full ">
|
||||||
|
{userAgent.os.name ? (
|
||||||
|
<div className="w-1/2">
|
||||||
|
<p className="text-sm text-muted-foreground">OS</p>
|
||||||
|
<p>{userAgent.os.name}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{userAgent.browser.name ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Browser</p>
|
||||||
|
<p>{userAgent.browser.name}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (status === "CLICKED") {
|
||||||
|
const _data = data as unknown as SesClick;
|
||||||
|
const userAgent = getUserAgent(_data.userAgent);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full mt-4 flex flex-col gap-4 rounded-xl p-4 bg-muted/20">
|
||||||
|
<div className="flex w-full ">
|
||||||
|
{userAgent.os.name ? (
|
||||||
|
<div className="w-1/2">
|
||||||
|
<p className="text-sm text-muted-foreground">OS </p>
|
||||||
|
<p>{userAgent.os.name}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{userAgent.browser.name ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Browser </p>
|
||||||
|
<p>{userAgent.browser.name}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<p className="text-sm text-muted-foreground">URL</p>
|
||||||
|
<p>{_data.link}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (status === "COMPLAINED") {
|
||||||
|
const _errorData = data as unknown as SesComplaint;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 w-full">
|
||||||
|
<p>{getComplaintMessage(_errorData.complaintFeedbackType)}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <div>{status}</div>;
|
|
||||||
|
return <div className="w-full">{status}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getErrorMessage = (data: SesBounce) => {
|
const getErrorMessage = (data: SesBounce) => {
|
||||||
@@ -148,3 +221,18 @@ const getErrorMessage = (data: SesBounce) => {
|
|||||||
return BOUNCE_ERROR_MESSAGES.Undetermined;
|
return BOUNCE_ERROR_MESSAGES.Undetermined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getComplaintMessage = (errorType: string) => {
|
||||||
|
return COMPLAINT_ERROR_MESSAGES[
|
||||||
|
errorType as keyof typeof COMPLAINT_ERROR_MESSAGES
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserAgent = (userAgent: string) => {
|
||||||
|
const parser = new UAParser(userAgent);
|
||||||
|
return {
|
||||||
|
browser: parser.getBrowser(),
|
||||||
|
os: parser.getOS(),
|
||||||
|
device: parser.getDevice(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
@@ -190,6 +190,7 @@ const EmailIcon: React.FC<{ status: EmailStatus }> = ({ status }) => {
|
|||||||
// </div>
|
// </div>
|
||||||
);
|
);
|
||||||
case "BOUNCED":
|
case "BOUNCED":
|
||||||
|
case "FAILED":
|
||||||
return (
|
return (
|
||||||
// <div className="border border-red-600/60 p-2 rounded-lg bg-red-500/10">
|
// <div className="border border-red-600/60 p-2 rounded-lg bg-red-500/10">
|
||||||
<MailX className="w-6 h-6 text-red-900" />
|
<MailX className="w-6 h-6 text-red-900" />
|
||||||
|
@@ -12,6 +12,7 @@ export const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({
|
|||||||
badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
|
badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
|
||||||
break;
|
break;
|
||||||
case "BOUNCED":
|
case "BOUNCED":
|
||||||
|
case "FAILED":
|
||||||
badgeColor = "bg-red-500/10 text-red-600 border-red-600/10";
|
badgeColor = "bg-red-500/10 text-red-600 border-red-600/10";
|
||||||
break;
|
break;
|
||||||
case "CLICKED":
|
case "CLICKED":
|
||||||
@@ -51,6 +52,7 @@ export const EmailStatusIcon: React.FC<{ status: EmailStatus }> = ({
|
|||||||
insideColor = "bg-emerald-500";
|
insideColor = "bg-emerald-500";
|
||||||
break;
|
break;
|
||||||
case "BOUNCED":
|
case "BOUNCED":
|
||||||
|
case "FAILED":
|
||||||
outsideColor = "bg-red-500/30";
|
outsideColor = "bg-red-500/30";
|
||||||
insideColor = "bg-red-500";
|
insideColor = "bg-red-500";
|
||||||
break;
|
break;
|
||||||
|
@@ -1,37 +1,6 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import {
|
|
||||||
BookOpenText,
|
|
||||||
BookUser,
|
|
||||||
CircleUser,
|
|
||||||
Code,
|
|
||||||
Globe,
|
|
||||||
Home,
|
|
||||||
LayoutDashboard,
|
|
||||||
LineChart,
|
|
||||||
LogOut,
|
|
||||||
Mail,
|
|
||||||
Menu,
|
|
||||||
Package,
|
|
||||||
Package2,
|
|
||||||
ShoppingCart,
|
|
||||||
Users,
|
|
||||||
Volume2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "@unsend/ui/src/button";
|
|
||||||
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@unsend/ui/src/dropdown-menu";
|
|
||||||
import { Sheet, SheetContent, SheetTrigger } from "@unsend/ui/src/sheet";
|
|
||||||
|
|
||||||
import { LogoutButton, NavButton } from "./nav-button";
|
|
||||||
import { DashboardProvider } from "~/providers/dashboard-provider";
|
import { DashboardProvider } from "~/providers/dashboard-provider";
|
||||||
import { NextAuthProvider } from "~/providers/next-auth";
|
import { NextAuthProvider } from "~/providers/next-auth";
|
||||||
|
import { DashboardLayout } from "./dasboard-layout";
|
||||||
|
|
||||||
export const dynamic = "force-static";
|
export const dynamic = "force-static";
|
||||||
|
|
||||||
@@ -43,158 +12,7 @@ export default function AuthenticatedDashboardLayout({
|
|||||||
return (
|
return (
|
||||||
<NextAuthProvider>
|
<NextAuthProvider>
|
||||||
<DashboardProvider>
|
<DashboardProvider>
|
||||||
<div className="flex min-h-screen w-full h-full">
|
<DashboardLayout>{children}</DashboardLayout>
|
||||||
<div className="hidden bg-muted/20 md:block md:w-[280px]">
|
|
||||||
<div className="flex h-full max-h-screen flex-col gap-2">
|
|
||||||
<div className="flex h-14 gap-4 items-center px-4 lg:h-[60px] lg:px-6">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="flex items-center gap-2 font-semibold"
|
|
||||||
>
|
|
||||||
<span className=" text-lg">Unsend</span>
|
|
||||||
</Link>
|
|
||||||
<span className="text-[10px] text-muted-foreground bg-muted p-0.5 px-2 rounded-full">
|
|
||||||
Early access
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 h-full">
|
|
||||||
<nav className=" flex-1 h-full flex-col justify-between items-center px-2 text-sm font-medium lg:px-4">
|
|
||||||
<div>
|
|
||||||
<NavButton href="/dashboard">
|
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
|
||||||
Dashboard
|
|
||||||
</NavButton>
|
|
||||||
|
|
||||||
<NavButton href="/emails">
|
|
||||||
<Mail className="h-4 w-4" />
|
|
||||||
Emails
|
|
||||||
</NavButton>
|
|
||||||
|
|
||||||
<NavButton href="/domains">
|
|
||||||
<Globe className="h-4 w-4" />
|
|
||||||
Domains
|
|
||||||
</NavButton>
|
|
||||||
|
|
||||||
<NavButton href="/contacts" comingSoon>
|
|
||||||
<BookUser className="h-4 w-4" />
|
|
||||||
Contacts
|
|
||||||
</NavButton>
|
|
||||||
|
|
||||||
<NavButton href="/contacts" comingSoon>
|
|
||||||
<Volume2 className="h-4 w-4" />
|
|
||||||
Marketing
|
|
||||||
</NavButton>
|
|
||||||
|
|
||||||
<NavButton href="/api-keys">
|
|
||||||
<Code className="h-4 w-4" />
|
|
||||||
Developer settings
|
|
||||||
</NavButton>
|
|
||||||
</div>
|
|
||||||
<div className=" absolute bottom-10 p-4 flex flex-col gap-2">
|
|
||||||
<Link
|
|
||||||
href="https://docs.unsend.dev"
|
|
||||||
target="_blank"
|
|
||||||
className="flex gap-2 items-center hover:text-primary text-muted-foreground"
|
|
||||||
>
|
|
||||||
<BookOpenText className="h-4 w-4" />
|
|
||||||
<span className="">Docs</span>
|
|
||||||
</Link>
|
|
||||||
<LogoutButton />
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div className="mt-auto p-4"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 flex-col">
|
|
||||||
<header className="flex h-14 items-center gap-4 md:hidden bg-muted/20 px-4 lg:h-[60px] lg:px-6">
|
|
||||||
<Sheet>
|
|
||||||
<SheetTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="shrink-0 md:hidden"
|
|
||||||
>
|
|
||||||
<Menu className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Toggle navigation menu</span>
|
|
||||||
</Button>
|
|
||||||
</SheetTrigger>
|
|
||||||
<SheetContent side="left" className="flex flex-col">
|
|
||||||
<nav className="grid gap-2 text-lg font-medium">
|
|
||||||
<Link
|
|
||||||
href="#"
|
|
||||||
className="flex items-center gap-2 text-lg font-semibold"
|
|
||||||
>
|
|
||||||
<Package2 className="h-6 w-6" />
|
|
||||||
<span className="sr-only">Acme Inc</span>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="#"
|
|
||||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Home className="h-5 w-5" />
|
|
||||||
Dashboard
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="#"
|
|
||||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl bg-muted px-3 py-2 text-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ShoppingCart className="h-5 w-5" />
|
|
||||||
Orders
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="#"
|
|
||||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Package className="h-5 w-5" />
|
|
||||||
Products
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="#"
|
|
||||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Users className="h-5 w-5" />
|
|
||||||
Customers
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="#"
|
|
||||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<LineChart className="h-5 w-5" />
|
|
||||||
Analytics
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
<div className="mt-auto"></div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
className="rounded-full"
|
|
||||||
>
|
|
||||||
<CircleUser className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Toggle user menu</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem>Settings</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>Support</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem>Logout</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</header>
|
|
||||||
<main className="flex-1 overflow-y-auto h-full">
|
|
||||||
<div className="flex flex-col gap-4 p-4 w-full lg:max-w-6xl mx-auto lg:gap-6 lg:p-6">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DashboardProvider>
|
</DashboardProvider>
|
||||||
</NextAuthProvider>
|
</NextAuthProvider>
|
||||||
);
|
);
|
||||||
|
@@ -1,8 +1,5 @@
|
|||||||
import { setupAws } from "~/server/aws/setup";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
await setupAws();
|
|
||||||
return Response.json({ data: "Healthy" });
|
return Response.json({ data: "Healthy" });
|
||||||
}
|
}
|
||||||
|
@@ -1,11 +1,11 @@
|
|||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { AppSettingsService } from "~/server/service/app-settings-service";
|
|
||||||
import { parseSesHook } from "~/server/service/ses-hook-parser";
|
import { parseSesHook } from "~/server/service/ses-hook-parser";
|
||||||
|
import { SesSettingsService } from "~/server/service/ses-settings-service";
|
||||||
import { SnsNotificationMessage } from "~/types/aws-types";
|
import { SnsNotificationMessage } from "~/types/aws-types";
|
||||||
import { APP_SETTINGS } from "~/utils/constants";
|
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export const dynamic = "force-dynamic";
|
||||||
console.log("GET", req);
|
|
||||||
|
export async function GET() {
|
||||||
return Response.json({ data: "Hello" });
|
return Response.json({ data: "Hello" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,10 +14,6 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
console.log(data, data.Message);
|
console.log(data, data.Message);
|
||||||
|
|
||||||
if (isFromUnsend(data)) {
|
|
||||||
return Response.json({ data: "success" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const isEventValid = await checkEventValidity(data);
|
const isEventValid = await checkEventValidity(data);
|
||||||
|
|
||||||
console.log("isEventValid: ", isEventValid);
|
console.log("isEventValid: ", isEventValid);
|
||||||
@@ -72,26 +68,17 @@ async function handleSubscription(message: any) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
SesSettingsService.invalidateCache();
|
||||||
|
|
||||||
return Response.json({ data: "Success" });
|
return Response.json({ data: "Success" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// A simple check to ensure that the event is from the correct topic
|
|
||||||
function isFromUnsend({ fromUnsend }: { fromUnsend: boolean }) {
|
|
||||||
if (fromUnsend) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A simple check to ensure that the event is from the correct topic
|
// A simple check to ensure that the event is from the correct topic
|
||||||
async function checkEventValidity(message: SnsNotificationMessage) {
|
async function checkEventValidity(message: SnsNotificationMessage) {
|
||||||
const { TopicArn } = message;
|
const { TopicArn } = message;
|
||||||
const configuredTopicArn = await AppSettingsService.getSetting(
|
const configuredTopicArn = await SesSettingsService.getTopicArns();
|
||||||
APP_SETTINGS.SNS_TOPIC_ARN
|
|
||||||
);
|
|
||||||
|
|
||||||
if (TopicArn !== configuredTopicArn) {
|
if (!configuredTopicArn.includes(TopicArn)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -6,7 +6,6 @@ import { Toaster } from "@unsend/ui/src/toaster";
|
|||||||
|
|
||||||
import { TRPCReactProvider } from "~/trpc/react";
|
import { TRPCReactProvider } from "~/trpc/react";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { getBoss } from "~/server/service/job-service";
|
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -24,12 +23,6 @@ export default async function RootLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
/**
|
|
||||||
* Because I don't know a better way to call this during server startup.
|
|
||||||
* This is a temporary fix to ensure that the boss is running.
|
|
||||||
*/
|
|
||||||
// await getBoss();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`font-sans ${inter.variable}`}>
|
<body className={`font-sans ${inter.variable}`}>
|
||||||
|
184
apps/web/src/components/settings/AddSesSettings.tsx
Normal file
184
apps/web/src/components/settings/AddSesSettings.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@unsend/ui/src/form";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { Input } from "@unsend/ui/src/input";
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import Spinner from "@unsend/ui/src/spinner";
|
||||||
|
import { toast } from "@unsend/ui/src/toaster";
|
||||||
|
|
||||||
|
const FormSchema = z.object({
|
||||||
|
region: z.string(),
|
||||||
|
unsendUrl: z.string().url(),
|
||||||
|
sendRate: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SesSettingsProps = {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddSesSettings: React.FC<SesSettingsProps> = ({ onSuccess }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen ">
|
||||||
|
<div className=" w-[400px] flex flex-col gap-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-center">
|
||||||
|
Add SES Settings
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<AddSesSettingsForm onSuccess={onSuccess} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddSesSettingsForm: React.FC<SesSettingsProps> = ({
|
||||||
|
onSuccess,
|
||||||
|
}) => {
|
||||||
|
const addSesSettings = api.admin.addSesSettings.useMutation();
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof FormSchema>>({
|
||||||
|
resolver: zodResolver(FormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
region: "",
|
||||||
|
unsendUrl: "",
|
||||||
|
sendRate: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(data: z.infer<typeof FormSchema>) {
|
||||||
|
if (!data.unsendUrl.startsWith("https://")) {
|
||||||
|
form.setError("unsendUrl", {
|
||||||
|
message: "URL must start with https://",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.unsendUrl.includes("localhost")) {
|
||||||
|
form.setError("unsendUrl", {
|
||||||
|
message: "URL must be a valid url",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addSesSettings.mutate(data, {
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.admin.invalidate();
|
||||||
|
onSuccess?.();
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
toast.error("Failed to create", {
|
||||||
|
description: e.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRegionInputOutOfFocus = async () => {
|
||||||
|
const region = form.getValues("region");
|
||||||
|
const quota = await utils.admin.getQuotaForRegion.fetch({ region });
|
||||||
|
form.setValue("sendRate", quota ?? 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className=" flex flex-col gap-8 w-full"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="region"
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Region</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="us-east-1"
|
||||||
|
className="w-full"
|
||||||
|
{...field}
|
||||||
|
onBlur={() => {
|
||||||
|
onRegionInputOutOfFocus();
|
||||||
|
field.onBlur();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{formState.errors.region ? (
|
||||||
|
<FormMessage />
|
||||||
|
) : (
|
||||||
|
<FormDescription>The region of the SES account</FormDescription>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="unsendUrl"
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Callback URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com"
|
||||||
|
className="w-full"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{formState.errors.unsendUrl ? (
|
||||||
|
<FormMessage />
|
||||||
|
) : (
|
||||||
|
<FormDescription>
|
||||||
|
This url should be accessible from the internet. Will be
|
||||||
|
called from SES
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="sendRate"
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Send Rate</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="1" className="w-full" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
{formState.errors.sendRate ? (
|
||||||
|
<FormMessage />
|
||||||
|
) : (
|
||||||
|
<FormDescription>
|
||||||
|
The number of emails to send per second.
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={addSesSettings.isPending}
|
||||||
|
className="w-[200px] mx-auto"
|
||||||
|
>
|
||||||
|
{addSesSettings.isPending ? (
|
||||||
|
<Spinner className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
"Create"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
@@ -1,3 +1,4 @@
|
|||||||
|
import { EmailStatus } from "@prisma/client";
|
||||||
import { createEnv } from "@t3-oss/env-nextjs";
|
import { createEnv } from "@t3-oss/env-nextjs";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -32,21 +33,19 @@ export const env = createEnv({
|
|||||||
GITHUB_SECRET: z.string(),
|
GITHUB_SECRET: z.string(),
|
||||||
AWS_ACCESS_KEY: z.string(),
|
AWS_ACCESS_KEY: z.string(),
|
||||||
AWS_SECRET_KEY: z.string(),
|
AWS_SECRET_KEY: z.string(),
|
||||||
APP_URL: z.string().optional(),
|
|
||||||
SNS_TOPIC: z.string(),
|
|
||||||
UNSEND_API_KEY: z.string().optional(),
|
UNSEND_API_KEY: z.string().optional(),
|
||||||
UNSEND_URL: z.string().optional(),
|
UNSEND_URL: z.string().optional(),
|
||||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||||
SES_QUEUE_LIMIT: z.string().transform((str) => parseInt(str, 10)),
|
|
||||||
AWS_DEFAULT_REGION: z.string().default("us-east-1"),
|
AWS_DEFAULT_REGION: z.string().default("us-east-1"),
|
||||||
API_RATE_LIMIT: z
|
API_RATE_LIMIT: z
|
||||||
.string()
|
.string()
|
||||||
.transform((str) => parseInt(str, 10))
|
.transform((str) => parseInt(str, 10))
|
||||||
.default(2),
|
.default(1),
|
||||||
FROM_EMAIL: z.string().optional(),
|
FROM_EMAIL: z.string().optional(),
|
||||||
ADMIN_EMAIL: z.string().optional(),
|
ADMIN_EMAIL: z.string().optional(),
|
||||||
DISCORD_WEBHOOK_URL: z.string().optional(),
|
DISCORD_WEBHOOK_URL: z.string().optional(),
|
||||||
|
REDIS_URL: z.string(),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,18 +71,16 @@ export const env = createEnv({
|
|||||||
GITHUB_SECRET: process.env.GITHUB_SECRET,
|
GITHUB_SECRET: process.env.GITHUB_SECRET,
|
||||||
AWS_ACCESS_KEY: process.env.AWS_ACCESS_KEY,
|
AWS_ACCESS_KEY: process.env.AWS_ACCESS_KEY,
|
||||||
AWS_SECRET_KEY: process.env.AWS_SECRET_KEY,
|
AWS_SECRET_KEY: process.env.AWS_SECRET_KEY,
|
||||||
APP_URL: process.env.APP_URL,
|
|
||||||
SNS_TOPIC: process.env.SNS_TOPIC,
|
|
||||||
UNSEND_API_KEY: process.env.UNSEND_API_KEY,
|
UNSEND_API_KEY: process.env.UNSEND_API_KEY,
|
||||||
UNSEND_URL: process.env.UNSEND_URL,
|
UNSEND_URL: process.env.UNSEND_URL,
|
||||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||||
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
|
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
|
||||||
SES_QUEUE_LIMIT: process.env.SES_QUEUE_LIMIT,
|
|
||||||
AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION,
|
AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION,
|
||||||
API_RATE_LIMIT: process.env.API_RATE_LIMIT,
|
API_RATE_LIMIT: process.env.API_RATE_LIMIT,
|
||||||
NEXT_PUBLIC_IS_CLOUD: process.env.NEXT_PUBLIC_IS_CLOUD,
|
NEXT_PUBLIC_IS_CLOUD: process.env.NEXT_PUBLIC_IS_CLOUD,
|
||||||
ADMIN_EMAIL: process.env.ADMIN_EMAIL,
|
ADMIN_EMAIL: process.env.ADMIN_EMAIL,
|
||||||
DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL,
|
DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL,
|
||||||
|
REDIS_URL: process.env.REDIS_URL,
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
|
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
|
||||||
|
19
apps/web/src/instrumentation.ts
Normal file
19
apps/web/src/instrumentation.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add things here to be executed during server startup.
|
||||||
|
*
|
||||||
|
* more details here: https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
|
||||||
|
*/
|
||||||
|
export async function register() {
|
||||||
|
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs" && !initialized) {
|
||||||
|
console.log("Registering instrumentation");
|
||||||
|
const { EmailQueueService } = await import(
|
||||||
|
"~/server/service/email-queue-service"
|
||||||
|
);
|
||||||
|
|
||||||
|
await EmailQueueService.init();
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
}
|
133
apps/web/src/lib/constants/example-codes.ts
Normal file
133
apps/web/src/lib/constants/example-codes.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { CodeBlock } from "@unsend/ui/src/code";
|
||||||
|
|
||||||
|
export const getSendTestEmailCode = ({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
bodyHtml,
|
||||||
|
}: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
bodyHtml: string;
|
||||||
|
}): Array<CodeBlock> => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
language: "js",
|
||||||
|
title: "Node.js",
|
||||||
|
code: `import { Unsend } from "unsend";
|
||||||
|
|
||||||
|
const unsend = new Unsend({ apiKey: "us_12345" });
|
||||||
|
|
||||||
|
unsend.emails.send({
|
||||||
|
to: "${to}",
|
||||||
|
from: "${from}",
|
||||||
|
subject: "${subject}",
|
||||||
|
html: "${bodyHtml}",
|
||||||
|
text: "${body}",
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
language: "python",
|
||||||
|
title: "Python",
|
||||||
|
code: `import requests
|
||||||
|
|
||||||
|
url = "https://app.unsend.dev/api/v1/emails"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"to": "${to}",
|
||||||
|
"from": "${from}",
|
||||||
|
"subject": "${subject}",
|
||||||
|
"text": "${body}",
|
||||||
|
"html": "${bodyHtml}",
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer us_12345"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.request("POST", url, json=payload, headers=headers)
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
language: "php",
|
||||||
|
title: "PHP",
|
||||||
|
code: `<?php
|
||||||
|
|
||||||
|
$curl = curl_init();
|
||||||
|
|
||||||
|
curl_setopt_array($curl, [
|
||||||
|
CURLOPT_URL => "https://app.unsend.dev/api/v1/emails",
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_ENCODING => "",
|
||||||
|
CURLOPT_MAXREDIRS => 10,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
||||||
|
CURLOPT_CUSTOMREQUEST => "POST",
|
||||||
|
CURLOPT_POSTFIELDS => "{\\n \\"to\\": \\"${to}\\",\\n \\"from\\": \\"${from}\\",\\n \\"subject\\": \\"${subject}\\",\\n \\"replyTo\\": \\"${from}\\",\\n \\"text\\": \\"${body}\\",\\n \\"html\\": \\"${bodyHtml}\\"\\n}",
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
"Authorization: Bearer us_12345",
|
||||||
|
"Content-Type: application/json"
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($curl);
|
||||||
|
$err = curl_error($curl);
|
||||||
|
|
||||||
|
curl_close($curl);
|
||||||
|
|
||||||
|
if ($err) {
|
||||||
|
echo "cURL Error #:" . $err;
|
||||||
|
} else {
|
||||||
|
echo $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
language: "ruby",
|
||||||
|
title: "Ruby",
|
||||||
|
code: `require 'net/http'
|
||||||
|
require 'uri'
|
||||||
|
require 'json'
|
||||||
|
|
||||||
|
url = URI("https://app.unsend.dev/api/v1/emails")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"to" => "${to}",
|
||||||
|
"from" => "${from}",
|
||||||
|
"subject" => "${subject}",
|
||||||
|
"text" => "${body}",
|
||||||
|
"html" => "${bodyHtml}"
|
||||||
|
}.to_json
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type" => "application/json",
|
||||||
|
"Authorization" => "Bearer us_12345"
|
||||||
|
}
|
||||||
|
|
||||||
|
http = Net::HTTP.new(url.host, url.port)
|
||||||
|
http.use_ssl = true
|
||||||
|
|
||||||
|
request = Net::HTTP::Post.new(url, headers)
|
||||||
|
request.body = payload
|
||||||
|
|
||||||
|
response = http.request(request)
|
||||||
|
|
||||||
|
puts response.body
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
language: "curl",
|
||||||
|
title: "cURL",
|
||||||
|
code: `curl -X POST https://app.unsend.dev/api/v1/emails \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-H "Authorization: Bearer us_12345" \\
|
||||||
|
-d '{"to": "${to}", "from": "${from}", "subject": "${subject}", "text": "${body}", "html": "${bodyHtml}"}'`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
@@ -44,3 +44,14 @@ export const BOUNCE_ERROR_MESSAGES = {
|
|||||||
"Unsend received an attachment rejected bounce. You may be able to successfully send to this recipient if you remove or change the attachment.",
|
"Unsend received an attachment rejected bounce. You may be able to successfully send to this recipient if you remove or change the attachment.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const COMPLAINT_ERROR_MESSAGES = {
|
||||||
|
abuse: "Indicates unsolicited email or some other kind of email abuse.",
|
||||||
|
"auth-failure": "Email authentication failure report.",
|
||||||
|
fraud: "Indicates some kind of fraud or phishing activity.",
|
||||||
|
"not-spam":
|
||||||
|
"Indicates that the entity providing the report does not consider the message to be spam. This may be used to correct a message that was incorrectly tagged or categorized as spam.",
|
||||||
|
other:
|
||||||
|
"Indicates any other feedback that does not fit into other registered types.",
|
||||||
|
virus: "Reports that a virus is found in the originating message.",
|
||||||
|
};
|
||||||
|
@@ -1,7 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import { FullScreenLoading } from "~/components/FullScreenLoading";
|
import { FullScreenLoading } from "~/components/FullScreenLoading";
|
||||||
|
import { AddSesSettings } from "~/components/settings/AddSesSettings";
|
||||||
import CreateTeam from "~/components/team/CreateTeam";
|
import CreateTeam from "~/components/team/CreateTeam";
|
||||||
|
import { env } from "~/env";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
export const DashboardProvider = ({
|
export const DashboardProvider = ({
|
||||||
@@ -9,12 +12,27 @@ export const DashboardProvider = ({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
|
const { data: session } = useSession();
|
||||||
const { data: teams, status } = api.team.getTeams.useQuery();
|
const { data: teams, status } = api.team.getTeams.useQuery();
|
||||||
|
const { data: settings, status: settingsStatus } =
|
||||||
|
api.admin.getSesSettings.useQuery(undefined, {
|
||||||
|
enabled: !env.NEXT_PUBLIC_IS_CLOUD || session?.user.isAdmin,
|
||||||
|
});
|
||||||
|
|
||||||
if (status === "pending") {
|
if (
|
||||||
|
status === "pending" ||
|
||||||
|
(settingsStatus === "pending" && !env.NEXT_PUBLIC_IS_CLOUD)
|
||||||
|
) {
|
||||||
return <FullScreenLoading />;
|
return <FullScreenLoading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
settings?.length === 0 &&
|
||||||
|
(!env.NEXT_PUBLIC_IS_CLOUD || session?.user.isAdmin)
|
||||||
|
) {
|
||||||
|
return <AddSesSettings />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!teams || teams.length === 0) {
|
if (!teams || teams.length === 0) {
|
||||||
return <CreateTeam />;
|
return <CreateTeam />;
|
||||||
}
|
}
|
||||||
|
@@ -3,6 +3,7 @@ import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
|||||||
import { apiRouter } from "./routers/api";
|
import { apiRouter } from "./routers/api";
|
||||||
import { emailRouter } from "./routers/email";
|
import { emailRouter } from "./routers/email";
|
||||||
import { teamRouter } from "./routers/team";
|
import { teamRouter } from "./routers/team";
|
||||||
|
import { adminRouter } from "./routers/admin";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the primary router for your server.
|
* This is the primary router for your server.
|
||||||
@@ -14,6 +15,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
apiKey: apiRouter,
|
apiKey: apiRouter,
|
||||||
email: emailRouter,
|
email: emailRouter,
|
||||||
team: teamRouter,
|
team: teamRouter,
|
||||||
|
admin: adminRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
@@ -3,12 +3,24 @@ import { env } from "~/env";
|
|||||||
|
|
||||||
import { createTRPCRouter, adminProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, adminProcedure } from "~/server/api/trpc";
|
||||||
import { SesSettingsService } from "~/server/service/ses-settings-service";
|
import { SesSettingsService } from "~/server/service/ses-settings-service";
|
||||||
|
import { getAccount } from "~/server/aws/ses";
|
||||||
|
|
||||||
export const adminRouter = createTRPCRouter({
|
export const adminRouter = createTRPCRouter({
|
||||||
getSesSettings: adminProcedure.query(async () => {
|
getSesSettings: adminProcedure.query(async () => {
|
||||||
return SesSettingsService.getAllSettings();
|
return SesSettingsService.getAllSettings();
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getQuotaForRegion: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
region: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const acc = await getAccount(input.region);
|
||||||
|
return acc.SendQuota?.MaxSendRate;
|
||||||
|
}),
|
||||||
|
|
||||||
addSesSettings: adminProcedure
|
addSesSettings: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
@@ -1,6 +1,10 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { createTRPCRouter, teamProcedure } from "~/server/api/trpc";
|
import {
|
||||||
|
createTRPCRouter,
|
||||||
|
teamProcedure,
|
||||||
|
protectedProcedure,
|
||||||
|
} from "~/server/api/trpc";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import {
|
import {
|
||||||
createDomain,
|
createDomain,
|
||||||
@@ -9,12 +13,18 @@ import {
|
|||||||
updateDomain,
|
updateDomain,
|
||||||
} from "~/server/service/domain-service";
|
} from "~/server/service/domain-service";
|
||||||
import { sendEmail } from "~/server/service/email-service";
|
import { sendEmail } from "~/server/service/email-service";
|
||||||
|
import { SesSettingsService } from "~/server/service/ses-settings-service";
|
||||||
|
|
||||||
export const domainRouter = createTRPCRouter({
|
export const domainRouter = createTRPCRouter({
|
||||||
|
getAvailableRegions: protectedProcedure.query(async () => {
|
||||||
|
const settings = await SesSettingsService.getAllSettings();
|
||||||
|
return settings.map((setting) => setting.region);
|
||||||
|
}),
|
||||||
|
|
||||||
createDomain: teamProcedure
|
createDomain: teamProcedure
|
||||||
.input(z.object({ name: z.string() }))
|
.input(z.object({ name: z.string(), region: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
return createDomain(ctx.team.id, input.name);
|
return createDomain(ctx.team.id, input.name, input.region);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
startVerification: teamProcedure
|
startVerification: teamProcedure
|
||||||
@@ -93,9 +103,9 @@ export const domainRouter = createTRPCRouter({
|
|||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
to: user.email,
|
to: user.email,
|
||||||
from: `hello@${domain.name}`,
|
from: `hello@${domain.name}`,
|
||||||
subject: "Test mail",
|
subject: "Unsend test email",
|
||||||
text: "Hello this is a test mail",
|
text: "hello,\n\nUnsend is the best open source sending platform\n\ncheck out https://unsend.dev",
|
||||||
html: "<p>Hello this is a test mail</p>",
|
html: "<p>hello,</p><p>Unsend is the best open source sending platform<p><p>check out <a href='https://unsend.dev'>unsend.dev</a>",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
@@ -25,6 +25,7 @@ declare module "next-auth" {
|
|||||||
user: {
|
user: {
|
||||||
id: number;
|
id: number;
|
||||||
isBetaUser: boolean;
|
isBetaUser: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
// ...other properties
|
// ...other properties
|
||||||
// role: UserRole;
|
// role: UserRole;
|
||||||
} & DefaultSession["user"];
|
} & DefaultSession["user"];
|
||||||
@@ -34,6 +35,7 @@ declare module "next-auth" {
|
|||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: number;
|
||||||
isBetaUser: boolean;
|
isBetaUser: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +88,7 @@ export const authOptions: NextAuthOptions = {
|
|||||||
...session.user,
|
...session.user,
|
||||||
id: user.id,
|
id: user.id,
|
||||||
isBetaUser: user.isBetaUser,
|
isBetaUser: user.isBetaUser,
|
||||||
|
isAdmin: user.email === env.ADMIN_EMAIL,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
@@ -8,14 +8,14 @@ import {
|
|||||||
CreateConfigurationSetEventDestinationCommand,
|
CreateConfigurationSetEventDestinationCommand,
|
||||||
CreateConfigurationSetCommand,
|
CreateConfigurationSetCommand,
|
||||||
EventType,
|
EventType,
|
||||||
|
GetAccountCommand,
|
||||||
} from "@aws-sdk/client-sesv2";
|
} from "@aws-sdk/client-sesv2";
|
||||||
import { generateKeyPairSync } from "crypto";
|
import { generateKeyPairSync } from "crypto";
|
||||||
import mime from "mime-types";
|
import mime from "mime-types";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { EmailContent } from "~/types";
|
import { EmailContent } from "~/types";
|
||||||
import { APP_SETTINGS } from "~/utils/constants";
|
|
||||||
|
|
||||||
function getSesClient(region = "us-east-1") {
|
function getSesClient(region: string) {
|
||||||
return new SESv2Client({
|
return new SESv2Client({
|
||||||
region: region,
|
region: region,
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -51,7 +51,7 @@ function generateKeyPair() {
|
|||||||
return { privateKey: base64PrivateKey, publicKey: base64PublicKey };
|
return { privateKey: base64PrivateKey, publicKey: base64PublicKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addDomain(domain: string, region = "us-east-1") {
|
export async function addDomain(domain: string, region: string) {
|
||||||
const sesClient = getSesClient(region);
|
const sesClient = getSesClient(region);
|
||||||
|
|
||||||
const { privateKey, publicKey } = generateKeyPair();
|
const { privateKey, publicKey } = generateKeyPair();
|
||||||
@@ -61,7 +61,6 @@ export async function addDomain(domain: string, region = "us-east-1") {
|
|||||||
DomainSigningSelector: "unsend",
|
DomainSigningSelector: "unsend",
|
||||||
DomainSigningPrivateKey: privateKey,
|
DomainSigningPrivateKey: privateKey,
|
||||||
},
|
},
|
||||||
ConfigurationSetName: APP_SETTINGS.SES_CONFIGURATION_GENERAL,
|
|
||||||
});
|
});
|
||||||
const response = await sesClient.send(command);
|
const response = await sesClient.send(command);
|
||||||
|
|
||||||
@@ -84,7 +83,7 @@ export async function addDomain(domain: string, region = "us-east-1") {
|
|||||||
return publicKey;
|
return publicKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteDomain(domain: string, region = "us-east-1") {
|
export async function deleteDomain(domain: string, region: string) {
|
||||||
const sesClient = getSesClient(region);
|
const sesClient = getSesClient(region);
|
||||||
const command = new DeleteEmailIdentityCommand({
|
const command = new DeleteEmailIdentityCommand({
|
||||||
EmailIdentity: domain,
|
EmailIdentity: domain,
|
||||||
@@ -93,7 +92,7 @@ export async function deleteDomain(domain: string, region = "us-east-1") {
|
|||||||
return response.$metadata.httpStatusCode === 200;
|
return response.$metadata.httpStatusCode === 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDomainIdentity(domain: string, region = "us-east-1") {
|
export async function getDomainIdentity(domain: string, region: string) {
|
||||||
const sesClient = getSesClient(region);
|
const sesClient = getSesClient(region);
|
||||||
const command = new GetEmailIdentityCommand({
|
const command = new GetEmailIdentityCommand({
|
||||||
EmailIdentity: domain,
|
EmailIdentity: domain,
|
||||||
@@ -106,21 +105,29 @@ export async function sendEmailThroughSes({
|
|||||||
to,
|
to,
|
||||||
from,
|
from,
|
||||||
subject,
|
subject,
|
||||||
|
cc,
|
||||||
|
bcc,
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
replyTo,
|
replyTo,
|
||||||
region = "us-east-1",
|
region,
|
||||||
configurationSetName,
|
configurationSetName,
|
||||||
}: EmailContent & {
|
}: Partial<EmailContent> & {
|
||||||
region?: string;
|
region: string;
|
||||||
configurationSetName: string;
|
configurationSetName: string;
|
||||||
|
cc?: string[];
|
||||||
|
bcc?: string[];
|
||||||
|
replyTo?: string[];
|
||||||
|
to?: string[];
|
||||||
}) {
|
}) {
|
||||||
const sesClient = getSesClient(region);
|
const sesClient = getSesClient(region);
|
||||||
const command = new SendEmailCommand({
|
const command = new SendEmailCommand({
|
||||||
FromEmailAddress: from,
|
FromEmailAddress: from,
|
||||||
ReplyToAddresses: replyTo ? [replyTo] : undefined,
|
ReplyToAddresses: replyTo ? replyTo : undefined,
|
||||||
Destination: {
|
Destination: {
|
||||||
ToAddresses: [to],
|
ToAddresses: to,
|
||||||
|
CcAddresses: cc,
|
||||||
|
BccAddresses: bcc,
|
||||||
},
|
},
|
||||||
Content: {
|
Content: {
|
||||||
// EmailContent
|
// EmailContent
|
||||||
@@ -153,7 +160,7 @@ export async function sendEmailThroughSes({
|
|||||||
return response.MessageId;
|
return response.MessageId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send email", error);
|
console.error("Failed to send email", error);
|
||||||
throw new Error("Failed to send email");
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,21 +170,29 @@ export async function sendEmailWithAttachments({
|
|||||||
from,
|
from,
|
||||||
subject,
|
subject,
|
||||||
replyTo,
|
replyTo,
|
||||||
|
cc,
|
||||||
|
bcc,
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
attachments,
|
attachments,
|
||||||
region = "us-east-1",
|
region,
|
||||||
configurationSetName,
|
configurationSetName,
|
||||||
}: EmailContent & {
|
}: Partial<EmailContent> & {
|
||||||
region?: string;
|
region: string;
|
||||||
configurationSetName: string;
|
configurationSetName: string;
|
||||||
attachments: { filename: string; content: string }[];
|
attachments: { filename: string; content: string }[];
|
||||||
|
cc?: string[];
|
||||||
|
bcc?: string[];
|
||||||
|
replyTo?: string[];
|
||||||
|
to?: string[];
|
||||||
}) {
|
}) {
|
||||||
const sesClient = getSesClient(region);
|
const sesClient = getSesClient(region);
|
||||||
const boundary = "NextPart";
|
const boundary = "NextPart";
|
||||||
let rawEmail = `From: ${from}\n`;
|
let rawEmail = `From: ${from}\n`;
|
||||||
rawEmail += `To: ${to}\n`;
|
rawEmail += `To: ${Array.isArray(to) ? to.join(", ") : to}\n`;
|
||||||
|
rawEmail += `Cc: ${cc ? cc.join(", ") : ""}\n`;
|
||||||
|
rawEmail += `Bcc: ${bcc ? bcc.join(", ") : ""}\n`;
|
||||||
rawEmail += `Reply-To: ${replyTo}\n`;
|
rawEmail += `Reply-To: ${replyTo}\n`;
|
||||||
rawEmail += `Subject: ${subject}\n`;
|
rawEmail += `Subject: ${subject}\n`;
|
||||||
rawEmail += `MIME-Version: 1.0\n`;
|
rawEmail += `MIME-Version: 1.0\n`;
|
||||||
@@ -217,11 +232,18 @@ export async function sendEmailWithAttachments({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAccount(region: string) {
|
||||||
|
const client = getSesClient(region);
|
||||||
|
const input = new GetAccountCommand({});
|
||||||
|
const response = await client.send(input);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
export async function addWebhookConfiguration(
|
export async function addWebhookConfiguration(
|
||||||
configName: string,
|
configName: string,
|
||||||
topicArn: string,
|
topicArn: string,
|
||||||
eventTypes: EventType[],
|
eventTypes: EventType[],
|
||||||
region = "us-east-1"
|
region: string
|
||||||
) {
|
) {
|
||||||
const sesClient = getSesClient(region);
|
const sesClient = getSesClient(region);
|
||||||
|
|
||||||
|
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
@@ -5,7 +5,7 @@ import {
|
|||||||
} from "@aws-sdk/client-sns";
|
} from "@aws-sdk/client-sns";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
|
|
||||||
function getSnsClient(region = "us-east-1") {
|
function getSnsClient(region: string) {
|
||||||
return new SNSClient({
|
return new SNSClient({
|
||||||
region: region,
|
region: region,
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -15,8 +15,8 @@ function getSnsClient(region = "us-east-1") {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTopic(topic: string) {
|
export async function createTopic(topic: string, region: string) {
|
||||||
const client = getSnsClient();
|
const client = getSnsClient(region);
|
||||||
const command = new CreateTopicCommand({
|
const command = new CreateTopicCommand({
|
||||||
Name: topic,
|
Name: topic,
|
||||||
});
|
});
|
||||||
@@ -25,13 +25,17 @@ export async function createTopic(topic: string) {
|
|||||||
return data.TopicArn;
|
return data.TopicArn;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function subscribeEndpoint(topicArn: string, endpointUrl: string) {
|
export async function subscribeEndpoint(
|
||||||
|
topicArn: string,
|
||||||
|
endpointUrl: string,
|
||||||
|
region: string
|
||||||
|
) {
|
||||||
const subscribeCommand = new SubscribeCommand({
|
const subscribeCommand = new SubscribeCommand({
|
||||||
Protocol: "https",
|
Protocol: "https",
|
||||||
TopicArn: topicArn,
|
TopicArn: topicArn,
|
||||||
Endpoint: endpointUrl,
|
Endpoint: endpointUrl,
|
||||||
});
|
});
|
||||||
const client = getSnsClient();
|
const client = getSnsClient(region);
|
||||||
|
|
||||||
const data = await client.send(subscribeCommand);
|
const data = await client.send(subscribeCommand);
|
||||||
console.log(data.SubscriptionArn);
|
console.log(data.SubscriptionArn);
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
import { createRoute, z } from "@hono/zod-openapi";
|
import { createRoute, z } from "@hono/zod-openapi";
|
||||||
import { PublicAPIApp } from "~/server/public-api/hono";
|
import { PublicAPIApp } from "~/server/public-api/hono";
|
||||||
import { getTeamFromToken } from "~/server/public-api/auth";
|
import { getTeamFromToken } from "~/server/public-api/auth";
|
||||||
import { sendEmail } from "~/server/service/email-service";
|
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { EmailStatus } from "@prisma/client";
|
import { EmailStatus } from "@prisma/client";
|
||||||
import { UnsendApiError } from "../../api-error";
|
import { UnsendApiError } from "../../api-error";
|
||||||
@@ -30,7 +29,10 @@ const route = createRoute({
|
|||||||
schema: z.object({
|
schema: z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
teamId: z.number(),
|
teamId: z.number(),
|
||||||
to: z.string(),
|
to: z.string().or(z.array(z.string())),
|
||||||
|
replyTo: z.string().or(z.array(z.string())).optional(),
|
||||||
|
cc: z.string().or(z.array(z.string())).optional(),
|
||||||
|
bcc: z.string().or(z.array(z.string())).optional(),
|
||||||
from: z.string(),
|
from: z.string(),
|
||||||
subject: z.string(),
|
subject: z.string(),
|
||||||
html: z.string().nullable(),
|
html: z.string().nullable(),
|
||||||
|
@@ -12,10 +12,12 @@ const route = createRoute({
|
|||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
to: z.string().email(),
|
to: z.string().or(z.array(z.string())),
|
||||||
from: z.string().email(),
|
from: z.string(),
|
||||||
subject: z.string(),
|
subject: z.string(),
|
||||||
replyTo: z.string().optional(),
|
replyTo: z.string().or(z.array(z.string())).optional(),
|
||||||
|
cc: z.string().or(z.array(z.string())).optional(),
|
||||||
|
bcc: z.string().or(z.array(z.string())).optional(),
|
||||||
text: z.string().optional(),
|
text: z.string().optional(),
|
||||||
html: z.string().optional(),
|
html: z.string().optional(),
|
||||||
attachments: z
|
attachments: z
|
||||||
|
@@ -15,7 +15,7 @@ export function getApp() {
|
|||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
title: "Unsend API",
|
title: "Unsend API",
|
||||||
},
|
},
|
||||||
servers: [{ url: `${env.APP_URL}/api` }],
|
servers: [{ url: `${env.NEXTAUTH_URL}/api` }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.openAPIRegistry.registerComponent("securitySchemes", "Bearer", {
|
app.openAPIRegistry.registerComponent("securitySchemes", "Bearer", {
|
||||||
|
11
apps/web/src/server/redis.ts
Normal file
11
apps/web/src/server/redis.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import IORedis from "ioredis";
|
||||||
|
import { env } from "~/env";
|
||||||
|
|
||||||
|
export let connection: IORedis | null = null;
|
||||||
|
|
||||||
|
export const getRedis = () => {
|
||||||
|
if (!connection) {
|
||||||
|
connection = new IORedis(env.REDIS_URL, { maxRetriesPerRequest: null });
|
||||||
|
}
|
||||||
|
return connection;
|
||||||
|
};
|
@@ -1,38 +0,0 @@
|
|||||||
import { db } from "../db";
|
|
||||||
import { JsonValue } from "@prisma/client/runtime/library";
|
|
||||||
|
|
||||||
export class AppSettingsService {
|
|
||||||
private static cache: Record<string, JsonValue> = {};
|
|
||||||
|
|
||||||
public static async getSetting(key: string) {
|
|
||||||
if (!this.cache[key]) {
|
|
||||||
const setting = await db.appSetting.findUnique({
|
|
||||||
where: { key },
|
|
||||||
});
|
|
||||||
if (setting) {
|
|
||||||
this.cache[key] = setting.value;
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.cache[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async setSetting(key: string, value: string) {
|
|
||||||
await db.appSetting.upsert({
|
|
||||||
where: { key },
|
|
||||||
update: { value },
|
|
||||||
create: { key, value },
|
|
||||||
});
|
|
||||||
this.cache[key] = value;
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async initializeCache(): Promise<void> {
|
|
||||||
const settings = await db.appSetting.findMany();
|
|
||||||
settings.forEach((setting) => {
|
|
||||||
this.cache[setting.key] = setting.value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@@ -3,18 +3,29 @@ import util from "util";
|
|||||||
import * as tldts from "tldts";
|
import * as tldts from "tldts";
|
||||||
import * as ses from "~/server/aws/ses";
|
import * as ses from "~/server/aws/ses";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
|
import { SesSettingsService } from "./ses-settings-service";
|
||||||
|
|
||||||
const dnsResolveTxt = util.promisify(dns.resolveTxt);
|
const dnsResolveTxt = util.promisify(dns.resolveTxt);
|
||||||
|
|
||||||
export async function createDomain(teamId: number, name: string) {
|
export async function createDomain(
|
||||||
|
teamId: number,
|
||||||
|
name: string,
|
||||||
|
region: string
|
||||||
|
) {
|
||||||
const domainStr = tldts.getDomain(name);
|
const domainStr = tldts.getDomain(name);
|
||||||
|
|
||||||
if (!domainStr) {
|
if (!domainStr) {
|
||||||
throw new Error("Invalid domain");
|
throw new Error("Invalid domain");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setting = await SesSettingsService.getSetting(region);
|
||||||
|
|
||||||
|
if (!setting) {
|
||||||
|
throw new Error("Ses setting not found");
|
||||||
|
}
|
||||||
|
|
||||||
const subdomain = tldts.getSubdomain(name);
|
const subdomain = tldts.getSubdomain(name);
|
||||||
const publicKey = await ses.addDomain(name);
|
const publicKey = await ses.addDomain(name, region);
|
||||||
|
|
||||||
const domain = await db.domain.create({
|
const domain = await db.domain.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -22,6 +33,7 @@ export async function createDomain(teamId: number, name: string) {
|
|||||||
publicKey,
|
publicKey,
|
||||||
teamId,
|
teamId,
|
||||||
subdomain,
|
subdomain,
|
||||||
|
region,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
135
apps/web/src/server/service/email-queue-service.ts
Normal file
135
apps/web/src/server/service/email-queue-service.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { Job, Queue, Worker } from "bullmq";
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { EmailAttachment } from "~/types";
|
||||||
|
import { getConfigurationSetName } from "~/utils/ses-utils";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { sendEmailThroughSes, sendEmailWithAttachments } from "../aws/ses";
|
||||||
|
import { getRedis } from "../redis";
|
||||||
|
|
||||||
|
export class EmailQueueService {
|
||||||
|
private static initialized = false;
|
||||||
|
private static regionQueue = new Map<string, Queue>();
|
||||||
|
private static regionWorker = new Map<string, Worker>();
|
||||||
|
|
||||||
|
public static initializeQueue(region: string, quota: number) {
|
||||||
|
const connection = getRedis();
|
||||||
|
console.log(`[EmailQueueService]: Initializing queue for region ${region}`);
|
||||||
|
|
||||||
|
const queueName = `${region}-transaction`;
|
||||||
|
|
||||||
|
const queue = new Queue(queueName, { connection });
|
||||||
|
|
||||||
|
const worker = new Worker(queueName, executeEmail, {
|
||||||
|
limiter: {
|
||||||
|
max: quota,
|
||||||
|
duration: 1000,
|
||||||
|
},
|
||||||
|
concurrency: quota,
|
||||||
|
connection,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.regionQueue.set(region, queue);
|
||||||
|
this.regionWorker.set(region, worker);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async queueEmail(emailId: string, region: string) {
|
||||||
|
if (!this.initialized) {
|
||||||
|
await this.init();
|
||||||
|
}
|
||||||
|
const queue = this.regionQueue.get(region);
|
||||||
|
if (!queue) {
|
||||||
|
throw new Error(`Queue for region ${region} not found`);
|
||||||
|
}
|
||||||
|
queue.add("send-email", { emailId, timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async init() {
|
||||||
|
const sesSettings = await db.sesSetting.findMany();
|
||||||
|
for (const sesSetting of sesSettings) {
|
||||||
|
this.initializeQueue(sesSetting.region, sesSetting.sesEmailRateLimit);
|
||||||
|
}
|
||||||
|
this.initialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeEmail(job: Job<{ emailId: string; timestamp: number }>) {
|
||||||
|
console.log(
|
||||||
|
`[EmailQueueService]: Executing email job ${job.data.emailId}, time elapsed: ${Date.now() - job.data.timestamp}ms`
|
||||||
|
);
|
||||||
|
|
||||||
|
const email = await db.email.findUnique({
|
||||||
|
where: { id: job.data.emailId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const domain = email?.domainId
|
||||||
|
? await db.domain.findUnique({
|
||||||
|
where: { id: email?.domainId },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
console.log(`[EmailQueueService]: Email not found, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments: Array<EmailAttachment> = email.attachments
|
||||||
|
? JSON.parse(email.attachments)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const configurationSetName = await getConfigurationSetName(
|
||||||
|
domain?.clickTracking ?? false,
|
||||||
|
domain?.openTracking ?? false,
|
||||||
|
domain?.region ?? env.AWS_DEFAULT_REGION
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!configurationSetName) {
|
||||||
|
console.log(`[EmailQueueService]: Configuration set not found, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[EmailQueueService]: Sending email ${email.id}`);
|
||||||
|
try {
|
||||||
|
const messageId = attachments.length
|
||||||
|
? await sendEmailWithAttachments({
|
||||||
|
to: email.to,
|
||||||
|
from: email.from,
|
||||||
|
subject: email.subject,
|
||||||
|
text: email.text ?? undefined,
|
||||||
|
html: email.html ?? undefined,
|
||||||
|
region: domain?.region ?? env.AWS_DEFAULT_REGION,
|
||||||
|
configurationSetName,
|
||||||
|
attachments,
|
||||||
|
})
|
||||||
|
: await sendEmailThroughSes({
|
||||||
|
to: email.to,
|
||||||
|
from: email.from,
|
||||||
|
subject: email.subject,
|
||||||
|
replyTo: email.replyTo ?? undefined,
|
||||||
|
text: email.text ?? undefined,
|
||||||
|
html: email.html ?? undefined,
|
||||||
|
region: domain?.region ?? env.AWS_DEFAULT_REGION,
|
||||||
|
configurationSetName,
|
||||||
|
attachments,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete attachments after sending the email
|
||||||
|
await db.email.update({
|
||||||
|
where: { id: email.id },
|
||||||
|
data: { sesEmailId: messageId, attachments: undefined },
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
await db.emailEvent.create({
|
||||||
|
data: {
|
||||||
|
emailId: email.id,
|
||||||
|
status: "FAILED",
|
||||||
|
data: {
|
||||||
|
error: error.toString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.email.update({
|
||||||
|
where: { id: email.id },
|
||||||
|
data: { latestStatus: "FAILED" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@@ -1,13 +1,23 @@
|
|||||||
import { EmailContent } from "~/types";
|
import { EmailContent } from "~/types";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { UnsendApiError } from "~/server/public-api/api-error";
|
import { UnsendApiError } from "~/server/public-api/api-error";
|
||||||
import { queueEmail } from "./job-service";
|
import { EmailQueueService } from "./email-queue-service";
|
||||||
|
|
||||||
export async function sendEmail(
|
export async function sendEmail(
|
||||||
emailContent: EmailContent & { teamId: number }
|
emailContent: EmailContent & { teamId: number }
|
||||||
) {
|
) {
|
||||||
const { to, from, subject, text, html, teamId, attachments, replyTo } =
|
const {
|
||||||
emailContent;
|
to,
|
||||||
|
from,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
teamId,
|
||||||
|
attachments,
|
||||||
|
replyTo,
|
||||||
|
cc,
|
||||||
|
bcc,
|
||||||
|
} = emailContent;
|
||||||
|
|
||||||
const fromDomain = from.split("@")[1];
|
const fromDomain = from.split("@")[1];
|
||||||
|
|
||||||
@@ -32,10 +42,16 @@ export async function sendEmail(
|
|||||||
|
|
||||||
const email = await db.email.create({
|
const email = await db.email.create({
|
||||||
data: {
|
data: {
|
||||||
to,
|
to: Array.isArray(to) ? to : [to],
|
||||||
from,
|
from,
|
||||||
subject,
|
subject,
|
||||||
replyTo,
|
replyTo: replyTo
|
||||||
|
? Array.isArray(replyTo)
|
||||||
|
? replyTo
|
||||||
|
: [replyTo]
|
||||||
|
: undefined,
|
||||||
|
cc: cc ? (Array.isArray(cc) ? cc : [cc]) : undefined,
|
||||||
|
bcc: bcc ? (Array.isArray(bcc) ? bcc : [bcc]) : undefined,
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
teamId,
|
teamId,
|
||||||
@@ -44,7 +60,24 @@ export async function sendEmail(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
queueEmail(email.id);
|
try {
|
||||||
|
await EmailQueueService.queueEmail(email.id, domain.region);
|
||||||
|
} catch (error: any) {
|
||||||
|
await db.emailEvent.create({
|
||||||
|
data: {
|
||||||
|
emailId: email.id,
|
||||||
|
status: "FAILED",
|
||||||
|
data: {
|
||||||
|
error: error.toString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.email.update({
|
||||||
|
where: { id: email.id },
|
||||||
|
data: { latestStatus: "FAILED" },
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
return email;
|
return email;
|
||||||
}
|
}
|
||||||
|
@@ -1,109 +0,0 @@
|
|||||||
import pgBoss from "pg-boss";
|
|
||||||
import { env } from "~/env";
|
|
||||||
import { EmailAttachment } from "~/types";
|
|
||||||
import { db } from "~/server/db";
|
|
||||||
import {
|
|
||||||
sendEmailThroughSes,
|
|
||||||
sendEmailWithAttachments,
|
|
||||||
} from "~/server/aws/ses";
|
|
||||||
import { getConfigurationSetName } from "~/utils/ses-utils";
|
|
||||||
import { sendToDiscord } from "./notification-service";
|
|
||||||
|
|
||||||
const boss = new pgBoss({
|
|
||||||
connectionString: env.DATABASE_URL,
|
|
||||||
archiveCompletedAfterSeconds: 60 * 60 * 24, // 24 hours
|
|
||||||
deleteAfterDays: 7, // 7 days
|
|
||||||
});
|
|
||||||
let started = false;
|
|
||||||
|
|
||||||
export async function getBoss() {
|
|
||||||
if (!started) {
|
|
||||||
await boss.start();
|
|
||||||
await boss.work(
|
|
||||||
"send_email",
|
|
||||||
{
|
|
||||||
teamConcurrency: env.SES_QUEUE_LIMIT,
|
|
||||||
teamSize: env.SES_QUEUE_LIMIT,
|
|
||||||
teamRefill: true,
|
|
||||||
},
|
|
||||||
executeEmail
|
|
||||||
);
|
|
||||||
|
|
||||||
boss.on("error", async (error) => {
|
|
||||||
console.error(error);
|
|
||||||
sendToDiscord(
|
|
||||||
`Error in pg-boss: ${error.name} \n ${error.cause}\n ${error.message}\n ${error.stack}`
|
|
||||||
);
|
|
||||||
await boss.stop();
|
|
||||||
started = false;
|
|
||||||
});
|
|
||||||
started = true;
|
|
||||||
}
|
|
||||||
return boss;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function queueEmail(emailId: string) {
|
|
||||||
const boss = await getBoss();
|
|
||||||
await boss.send("send_email", { emailId, timestamp: Date.now() });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeEmail(
|
|
||||||
job: pgBoss.Job<{ emailId: string; timestamp: number }>
|
|
||||||
) {
|
|
||||||
console.log(
|
|
||||||
`[EmailJob]: Executing email job ${job.data.emailId}, time elapsed: ${Date.now() - job.data.timestamp}ms`
|
|
||||||
);
|
|
||||||
|
|
||||||
const email = await db.email.findUnique({
|
|
||||||
where: { id: job.data.emailId },
|
|
||||||
});
|
|
||||||
|
|
||||||
const domain = email?.domainId
|
|
||||||
? await db.domain.findUnique({
|
|
||||||
where: { id: email?.domainId },
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!email) {
|
|
||||||
console.log(`[EmailJob]: Email not found, skipping`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachments: Array<EmailAttachment> = email.attachments
|
|
||||||
? JSON.parse(email.attachments)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const messageId = attachments.length
|
|
||||||
? await sendEmailWithAttachments({
|
|
||||||
to: email.to,
|
|
||||||
from: email.from,
|
|
||||||
subject: email.subject,
|
|
||||||
text: email.text ?? undefined,
|
|
||||||
html: email.html ?? undefined,
|
|
||||||
region: domain?.region ?? env.AWS_DEFAULT_REGION,
|
|
||||||
configurationSetName: getConfigurationSetName(
|
|
||||||
domain?.clickTracking ?? false,
|
|
||||||
domain?.openTracking ?? false
|
|
||||||
),
|
|
||||||
attachments,
|
|
||||||
})
|
|
||||||
: await sendEmailThroughSes({
|
|
||||||
to: email.to,
|
|
||||||
from: email.from,
|
|
||||||
subject: email.subject,
|
|
||||||
replyTo: email.replyTo ?? undefined,
|
|
||||||
text: email.text ?? undefined,
|
|
||||||
html: email.html ?? undefined,
|
|
||||||
region: domain?.region ?? env.AWS_DEFAULT_REGION,
|
|
||||||
configurationSetName: getConfigurationSetName(
|
|
||||||
domain?.clickTracking ?? false,
|
|
||||||
domain?.openTracking ?? false
|
|
||||||
),
|
|
||||||
attachments,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.email.update({
|
|
||||||
where: { id: email.id },
|
|
||||||
data: { sesEmailId: messageId, attachments: undefined },
|
|
||||||
});
|
|
||||||
}
|
|
@@ -2,6 +2,8 @@ import { EmailStatus } from "@prisma/client";
|
|||||||
import { SesEvent, SesEventDataKey } from "~/types/aws-types";
|
import { SesEvent, SesEventDataKey } from "~/types/aws-types";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
|
|
||||||
|
const STATUS_LIST = Object.values(EmailStatus);
|
||||||
|
|
||||||
export async function parseSesHook(data: SesEvent) {
|
export async function parseSesHook(data: SesEvent) {
|
||||||
const mailStatus = getEmailStatus(data);
|
const mailStatus = getEmailStatus(data);
|
||||||
|
|
||||||
@@ -30,21 +32,12 @@ export async function parseSesHook(data: SesEvent) {
|
|||||||
id: email.id,
|
id: email.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
latestStatus: mailStatus,
|
latestStatus: getLatestStatus(email.latestStatus, mailStatus),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.emailEvent.upsert({
|
await db.emailEvent.create({
|
||||||
where: {
|
data: {
|
||||||
emailId_status: {
|
|
||||||
emailId: email.id,
|
|
||||||
status: mailStatus,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
data: mailData as any,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
emailId: email.id,
|
emailId: email.id,
|
||||||
status: mailStatus,
|
status: mailStatus,
|
||||||
data: mailData as any,
|
data: mailData as any,
|
||||||
@@ -89,3 +82,12 @@ function getEmailData(data: SesEvent) {
|
|||||||
return data[eventType.toLowerCase() as SesEventDataKey];
|
return data[eventType.toLowerCase() as SesEventDataKey];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLatestStatus(
|
||||||
|
currentEmailStatus: EmailStatus,
|
||||||
|
incomingStatus: EmailStatus
|
||||||
|
) {
|
||||||
|
const index = STATUS_LIST.indexOf(currentEmailStatus);
|
||||||
|
const incomingIndex = STATUS_LIST.indexOf(incomingStatus);
|
||||||
|
return STATUS_LIST[Math.max(index, incomingIndex)] ?? incomingStatus;
|
||||||
|
}
|
||||||
|
@@ -5,8 +5,9 @@ import { customAlphabet } from "nanoid";
|
|||||||
import * as sns from "~/server/aws/sns";
|
import * as sns from "~/server/aws/sns";
|
||||||
import * as ses from "~/server/aws/ses";
|
import * as ses from "~/server/aws/ses";
|
||||||
import { EventType } from "@aws-sdk/client-sesv2";
|
import { EventType } from "@aws-sdk/client-sesv2";
|
||||||
|
import { EmailQueueService } from "./email-queue-service";
|
||||||
|
|
||||||
const nanoid = customAlphabet("1234567890abcdef", 10);
|
const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 10);
|
||||||
|
|
||||||
const GENERAL_EVENTS: EventType[] = [
|
const GENERAL_EVENTS: EventType[] = [
|
||||||
"BOUNCE",
|
"BOUNCE",
|
||||||
@@ -21,15 +22,26 @@ const GENERAL_EVENTS: EventType[] = [
|
|||||||
|
|
||||||
export class SesSettingsService {
|
export class SesSettingsService {
|
||||||
private static cache: Record<string, SesSetting> = {};
|
private static cache: Record<string, SesSetting> = {};
|
||||||
|
private static topicArns: Array<string> = [];
|
||||||
|
private static initialized = false;
|
||||||
|
|
||||||
public static getSetting(region = env.AWS_DEFAULT_REGION): SesSetting | null {
|
public static async getSetting(
|
||||||
|
region = env.AWS_DEFAULT_REGION
|
||||||
|
): Promise<SesSetting | null> {
|
||||||
|
await this.checkInitialized();
|
||||||
if (this.cache[region]) {
|
if (this.cache[region]) {
|
||||||
return this.cache[region] as SesSetting;
|
return this.cache[region] as SesSetting;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static getAllSettings() {
|
public static async getTopicArns() {
|
||||||
|
await this.checkInitialized();
|
||||||
|
return this.topicArns;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getAllSettings() {
|
||||||
|
await this.checkInitialized();
|
||||||
return Object.values(this.cache);
|
return Object.values(this.cache);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,15 +58,20 @@ export class SesSettingsService {
|
|||||||
region: string;
|
region: string;
|
||||||
unsendUrl: string;
|
unsendUrl: string;
|
||||||
}) {
|
}) {
|
||||||
|
await this.checkInitialized();
|
||||||
if (this.cache[region]) {
|
if (this.cache[region]) {
|
||||||
throw new Error(`SesSetting for region ${region} already exists`);
|
throw new Error(`SesSetting for region ${region} already exists`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsendUrlValidation = await isValidUnsendUrl(unsendUrl);
|
const parsedUrl = unsendUrl.endsWith("/")
|
||||||
|
? unsendUrl.substring(0, unsendUrl.length - 1)
|
||||||
|
: unsendUrl;
|
||||||
|
|
||||||
|
const unsendUrlValidation = await isValidUnsendUrl(parsedUrl);
|
||||||
|
|
||||||
if (!unsendUrlValidation.isValid) {
|
if (!unsendUrlValidation.isValid) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unsend URL ${unsendUrl} is not valid, status: ${unsendUrlValidation.code} ${unsendUrlValidation.error}`
|
`Unsend URL: ${unsendUrl} is not valid, status: ${unsendUrlValidation.code} message:${unsendUrlValidation.error}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,28 +80,35 @@ export class SesSettingsService {
|
|||||||
const setting = await db.sesSetting.create({
|
const setting = await db.sesSetting.create({
|
||||||
data: {
|
data: {
|
||||||
region,
|
region,
|
||||||
callbackUrl: `${unsendUrl}/api/ses_callback`,
|
callbackUrl: `${parsedUrl}/api/ses_callback`,
|
||||||
topic: `${idPrefix}-${region}-unsend`,
|
topic: `${idPrefix}-${region}-unsend`,
|
||||||
idPrefix,
|
idPrefix,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await createSettingInAws(setting);
|
await createSettingInAws(setting);
|
||||||
|
EmailQueueService.initializeQueue(region, setting.sesEmailRateLimit);
|
||||||
|
|
||||||
this.invalidateCache();
|
await this.invalidateCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async init() {
|
public static async checkInitialized() {
|
||||||
|
if (!this.initialized) {
|
||||||
|
await this.invalidateCache();
|
||||||
|
this.initialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async invalidateCache() {
|
||||||
|
this.cache = {};
|
||||||
const settings = await db.sesSetting.findMany();
|
const settings = await db.sesSetting.findMany();
|
||||||
settings.forEach((setting) => {
|
settings.forEach((setting) => {
|
||||||
this.cache[setting.region] = setting;
|
this.cache[setting.region] = setting;
|
||||||
|
if (setting.topicArn) {
|
||||||
|
this.topicArns.push(setting.topicArn);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static invalidateCache() {
|
|
||||||
this.cache = {};
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSettingInAws(setting: SesSetting) {
|
async function createSettingInAws(setting: SesSetting) {
|
||||||
@@ -95,18 +119,13 @@ async function createSettingInAws(setting: SesSetting) {
|
|||||||
* Creates a new topic in AWS and subscribes the callback URL to it
|
* Creates a new topic in AWS and subscribes the callback URL to it
|
||||||
*/
|
*/
|
||||||
async function registerTopicInAws(setting: SesSetting) {
|
async function registerTopicInAws(setting: SesSetting) {
|
||||||
const topicArn = await sns.createTopic(setting.topic);
|
const topicArn = await sns.createTopic(setting.topic, setting.region);
|
||||||
|
|
||||||
if (!topicArn) {
|
if (!topicArn) {
|
||||||
throw new Error("Failed to create SNS topic");
|
throw new Error("Failed to create SNS topic");
|
||||||
}
|
}
|
||||||
|
|
||||||
await sns.subscribeEndpoint(
|
const _setting = await db.sesSetting.update({
|
||||||
topicArn,
|
|
||||||
`${setting.callbackUrl}/api/ses_callback`
|
|
||||||
);
|
|
||||||
|
|
||||||
return await db.sesSetting.update({
|
|
||||||
where: {
|
where: {
|
||||||
id: setting.id,
|
id: setting.id,
|
||||||
},
|
},
|
||||||
@@ -114,6 +133,17 @@ async function registerTopicInAws(setting: SesSetting) {
|
|||||||
topicArn,
|
topicArn,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Invalidate the cache to update the topicArn list
|
||||||
|
SesSettingsService.invalidateCache();
|
||||||
|
|
||||||
|
await sns.subscribeEndpoint(
|
||||||
|
topicArn,
|
||||||
|
`${setting.callbackUrl}`,
|
||||||
|
setting.region
|
||||||
|
);
|
||||||
|
|
||||||
|
return _setting;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,28 +163,32 @@ async function registerConfigurationSet(setting: SesSetting) {
|
|||||||
const generalStatus = await ses.addWebhookConfiguration(
|
const generalStatus = await ses.addWebhookConfiguration(
|
||||||
configGeneral,
|
configGeneral,
|
||||||
setting.topicArn,
|
setting.topicArn,
|
||||||
GENERAL_EVENTS
|
GENERAL_EVENTS,
|
||||||
|
setting.region
|
||||||
);
|
);
|
||||||
|
|
||||||
const configClick = `${setting.idPrefix}-${setting.region}-unsend-click`;
|
const configClick = `${setting.idPrefix}-${setting.region}-unsend-click`;
|
||||||
const clickStatus = await ses.addWebhookConfiguration(
|
const clickStatus = await ses.addWebhookConfiguration(
|
||||||
configClick,
|
configClick,
|
||||||
setting.topicArn,
|
setting.topicArn,
|
||||||
[...GENERAL_EVENTS, "CLICK"]
|
[...GENERAL_EVENTS, "CLICK"],
|
||||||
|
setting.region
|
||||||
);
|
);
|
||||||
|
|
||||||
const configOpen = `${setting.idPrefix}-${setting.region}-unsend-open`;
|
const configOpen = `${setting.idPrefix}-${setting.region}-unsend-open`;
|
||||||
const openStatus = await ses.addWebhookConfiguration(
|
const openStatus = await ses.addWebhookConfiguration(
|
||||||
configOpen,
|
configOpen,
|
||||||
setting.topicArn,
|
setting.topicArn,
|
||||||
[...GENERAL_EVENTS, "OPEN"]
|
[...GENERAL_EVENTS, "OPEN"],
|
||||||
|
setting.region
|
||||||
);
|
);
|
||||||
|
|
||||||
const configFull = `${setting.idPrefix}-${setting.region}-unsend-full`;
|
const configFull = `${setting.idPrefix}-${setting.region}-unsend-full`;
|
||||||
const fullStatus = await ses.addWebhookConfiguration(
|
const fullStatus = await ses.addWebhookConfiguration(
|
||||||
configFull,
|
configFull,
|
||||||
setting.topicArn,
|
setting.topicArn,
|
||||||
[...GENERAL_EVENTS, "CLICK", "OPEN"]
|
[...GENERAL_EVENTS, "CLICK", "OPEN"],
|
||||||
|
setting.region
|
||||||
);
|
);
|
||||||
|
|
||||||
return await db.sesSetting.update({
|
return await db.sesSetting.update({
|
||||||
@@ -175,10 +209,10 @@ async function registerConfigurationSet(setting: SesSetting) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function isValidUnsendUrl(url: string) {
|
async function isValidUnsendUrl(url: string) {
|
||||||
|
console.log("Checking if URL is valid", url);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${url}/api/ses_callback`, {
|
const response = await fetch(`${url}/api/ses_callback`, {
|
||||||
method: "POST",
|
method: "GET",
|
||||||
body: JSON.stringify({ fromUnsend: true }),
|
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
isValid: response.status === 200,
|
isValid: response.status === 200,
|
||||||
@@ -186,6 +220,7 @@ async function isValidUnsendUrl(url: string) {
|
|||||||
error: response.statusText,
|
error: response.statusText,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.log("Error checking if URL is valid", e);
|
||||||
return {
|
return {
|
||||||
isValid: false,
|
isValid: false,
|
||||||
code: 500,
|
code: 500,
|
||||||
|
@@ -1,10 +1,12 @@
|
|||||||
export type EmailContent = {
|
export type EmailContent = {
|
||||||
to: string;
|
to: string | string[];
|
||||||
from: string;
|
from: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
html?: string;
|
html?: string;
|
||||||
replyTo?: string;
|
replyTo?: string | string[];
|
||||||
|
cc?: string | string[];
|
||||||
|
bcc?: string | string[];
|
||||||
attachments?: Array<EmailAttachment>;
|
attachments?: Array<EmailAttachment>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -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}`,
|
|
||||||
};
|
|
@@ -1,18 +1,25 @@
|
|||||||
import { APP_SETTINGS } from "./constants";
|
import { SesSettingsService } from "~/server/service/ses-settings-service";
|
||||||
|
|
||||||
export function getConfigurationSetName(
|
export async function getConfigurationSetName(
|
||||||
clickTracking: boolean,
|
clickTracking: boolean,
|
||||||
openTracking: boolean
|
openTracking: boolean,
|
||||||
|
region: string
|
||||||
) {
|
) {
|
||||||
|
const setting = await SesSettingsService.getSetting(region);
|
||||||
|
|
||||||
|
if (!setting) {
|
||||||
|
throw new Error(`No SES setting found for region: ${region}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (clickTracking && openTracking) {
|
if (clickTracking && openTracking) {
|
||||||
return APP_SETTINGS.SES_CONFIGURATION_FULL;
|
return setting.configFull;
|
||||||
}
|
}
|
||||||
if (clickTracking) {
|
if (clickTracking) {
|
||||||
return APP_SETTINGS.SES_CONFIGURATION_CLICK_TRACKING;
|
return setting.configClick;
|
||||||
}
|
}
|
||||||
if (openTracking) {
|
if (openTracking) {
|
||||||
return APP_SETTINGS.SES_CONFIGURATION_OPEN_TRACKING;
|
return setting.configOpen;
|
||||||
}
|
}
|
||||||
|
|
||||||
return APP_SETTINGS.SES_CONFIGURATION_GENERAL;
|
return setting.configGeneral;
|
||||||
}
|
}
|
||||||
|
@@ -4,6 +4,7 @@ ENV PATH="$PNPM_HOME:$PATH"
|
|||||||
ENV SKIP_ENV_VALIDATION="true"
|
ENV SKIP_ENV_VALIDATION="true"
|
||||||
ENV DOCKER_OUTPUT 1
|
ENV DOCKER_OUTPUT 1
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
ENV NEXT_PUBLIC_IS_CLOUD="false"
|
||||||
|
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ RUN apk update
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# Replace <your-major-version> with the major version installed in your repository. For example:
|
# Replace <your-major-version> with the major version installed in your repository. For example:
|
||||||
# RUN yarn global add turbo@^2
|
# RUN yarn global add turbo@^2
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json start.sh ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||||
COPY ./apps/web ./apps/web
|
COPY ./apps/web ./apps/web
|
||||||
COPY ./packages ./packages
|
COPY ./packages ./packages
|
||||||
RUN pnpm add turbo@^1.12.5 -g
|
RUN pnpm add turbo@^1.12.5 -g
|
||||||
@@ -71,6 +72,6 @@ RUN ln -s /app/node_modules/prisma/build/index.js ./node_modules/.bin/prisma
|
|||||||
# set this so it throws error where starting server
|
# set this so it throws error where starting server
|
||||||
ENV SKIP_ENV_VALIDATION="false"
|
ENV SKIP_ENV_VALIDATION="false"
|
||||||
|
|
||||||
COPY start.sh ./
|
COPY ./docker/start.sh ./start.sh
|
||||||
|
|
||||||
CMD ["sh", "start.sh"]
|
CMD ["sh", "start.sh"]
|
26
docker/build.sh
Normal file
26
docker/build.sh
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 || {
|
||||||
|
echo "Docker is not running. Please start Docker and try again."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
||||||
|
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
||||||
|
|
||||||
|
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
||||||
|
GIT_SHA="$(git rev-parse HEAD)"
|
||||||
|
|
||||||
|
echo "Building docker image for monorepo at $MONOREPO_ROOT"
|
||||||
|
echo "App version: $APP_VERSION"
|
||||||
|
echo "Git SHA: $GIT_SHA"
|
||||||
|
|
||||||
|
docker build -f "$SCRIPT_DIR/Dockerfile" \
|
||||||
|
--progress=plain \
|
||||||
|
-t "unsend/unsend:latest" \
|
||||||
|
-t "unsend/unsend:$GIT_SHA" \
|
||||||
|
-t "unsend/unsend:$APP_VERSION" \
|
||||||
|
-t "ghcr.io/unsend/unsend:latest" \
|
||||||
|
-t "ghcr.io/unsend/unsend:$GIT_SHA" \
|
||||||
|
-t "ghcr.io/unsend/unsend:$APP_VERSION" \
|
||||||
|
"$MONOREPO_ROOT"
|
29
docker/dev/compose.yml
Normal file
29
docker/dev/compose.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
name: unsend-dev
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: unsend-db-dev
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=unsend
|
||||||
|
- POSTGRES_PASSWORD=password
|
||||||
|
- POSTGRES_DB=unsend
|
||||||
|
volumes:
|
||||||
|
- database:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "54320:5432"
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7
|
||||||
|
container_name: unsend-redis-dev
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis:/data
|
||||||
|
command: ["redis-server", "--maxmemory-policy", "noeviction"]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
database:
|
||||||
|
redis:
|
@@ -3,7 +3,7 @@ name: unsend-prod
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
container_name: postgres
|
container_name: unsend-db-prod
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=${POSTGRES_USER:?err}
|
- POSTGRES_USER=${POSTGRES_USER:?err}
|
||||||
@@ -14,19 +14,23 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
# ports:
|
||||||
|
# - "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- database:/var/lib/postgresql/data
|
- database:/var/lib/postgresql/data
|
||||||
|
|
||||||
# You don't need to expose this port to the host since, docker compose creates an internal network
|
redis:
|
||||||
# through which both of these containers could talk to each other using their container_name as hostname
|
image: redis:7
|
||||||
# But if you want to connect this to a querying tool to debug you can definitely uncomment this
|
container_name: unsend-redis-prod
|
||||||
|
restart: always
|
||||||
# ports:
|
# ports:
|
||||||
# - "5432:5432"
|
# - "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- cache:/data
|
||||||
|
command: ["redis-server", "--maxmemory-policy", "noeviction"]
|
||||||
|
|
||||||
unsend:
|
unsend:
|
||||||
build:
|
image: unsend/unsend:latest
|
||||||
dockerfile: Dockerfile
|
|
||||||
image: unsend
|
|
||||||
container_name: unsend
|
container_name: unsend
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
@@ -41,16 +45,15 @@ services:
|
|||||||
- AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:?err}
|
- AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:?err}
|
||||||
- GITHUB_ID=${GITHUB_ID:?err}
|
- GITHUB_ID=${GITHUB_ID:?err}
|
||||||
- GITHUB_SECRET=${GITHUB_SECRET:?err}
|
- GITHUB_SECRET=${GITHUB_SECRET:?err}
|
||||||
- APP_URL=${APP_URL:-${NEXTAUTH_URL}}
|
- REDIS_URL=${REDIS_URL:?err}
|
||||||
- SNS_TOPIC=${SNS_TOPIC:?err}
|
|
||||||
- NEXT_PUBLIC_IS_CLOUD=${NEXT_PUBLIC_IS_CLOUD:-false}
|
- NEXT_PUBLIC_IS_CLOUD=${NEXT_PUBLIC_IS_CLOUD:-false}
|
||||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
|
||||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
|
||||||
- SES_QUEUE_LIMIT=${SES_QUEUE_LIMIT:-1}
|
|
||||||
- API_RATE_LIMIT=${API_RATE_LIMIT:-1}
|
- API_RATE_LIMIT=${API_RATE_LIMIT:-1}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
database:
|
database:
|
||||||
|
cache:
|
@@ -13,9 +13,14 @@
|
|||||||
"db:push": "pnpm db db:push",
|
"db:push": "pnpm db db:push",
|
||||||
"db:migrate-dev": "pnpm db db:migrate-dev",
|
"db:migrate-dev": "pnpm db db:migrate-dev",
|
||||||
"db:migrate-deploy": "pnpm db db:migrate-deploy",
|
"db:migrate-deploy": "pnpm db db:migrate-deploy",
|
||||||
|
"db:migrate-reset": "pnpm db db:migrate-reset",
|
||||||
"db:studio": "pnpm db db:studio",
|
"db:studio": "pnpm db db:studio",
|
||||||
"db": "pnpm load-env -- pnpm --filter=web",
|
"db": "pnpm load-env -- pnpm --filter=web",
|
||||||
"load-env": "dotenv -e .env"
|
"load-env": "dotenv -e .env",
|
||||||
|
"d": "pnpm dx && pnpm dev",
|
||||||
|
"dx": "pnpm i && pnpm dx:up && pnpm db:migrate-dev",
|
||||||
|
"dx:up": "docker compose -f docker/dev/compose.yml up -d",
|
||||||
|
"dx:down": "docker compose -f docker/dev/compose.yml down"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@unsend/eslint-config": "workspace:*",
|
"@unsend/eslint-config": "workspace:*",
|
||||||
|
@@ -9,7 +9,8 @@
|
|||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"build": "rm -rf dist && tsup index.ts --format esm,cjs --dts",
|
"build": "rm -rf dist && tsup index.ts --format esm,cjs --dts",
|
||||||
"publish-sdk": "pnpm run build && pnpm publish"
|
"publish-sdk": "pnpm run build && pnpm publish",
|
||||||
|
"openapi-typegen": "openapi-typescript ../../apps/docs/api-reference/openapi.json -o types/schema.d.ts"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
12
packages/sdk/types/schema.d.ts
vendored
12
packages/sdk/types/schema.d.ts
vendored
@@ -60,7 +60,10 @@ export interface paths {
|
|||||||
"application/json": {
|
"application/json": {
|
||||||
id: string;
|
id: string;
|
||||||
teamId: number;
|
teamId: number;
|
||||||
to: string;
|
to: string | string[];
|
||||||
|
replyTo?: string | string[];
|
||||||
|
cc?: string | string[];
|
||||||
|
bcc?: string | string[];
|
||||||
from: string;
|
from: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
html: string | null;
|
html: string | null;
|
||||||
@@ -85,12 +88,13 @@ export interface paths {
|
|||||||
requestBody: {
|
requestBody: {
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
/** Format: email */
|
to: string | string[];
|
||||||
to: string;
|
|
||||||
/** Format: email */
|
/** Format: email */
|
||||||
from: string;
|
from: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
replyTo?: string;
|
replyTo?: string | string[];
|
||||||
|
cc?: string | string[];
|
||||||
|
bcc?: string | string[];
|
||||||
text?: string;
|
text?: string;
|
||||||
html?: string;
|
html?: string;
|
||||||
attachments?: {
|
attachments?: {
|
||||||
|
@@ -11,13 +11,16 @@ import { ClipboardCopy, Check } from "lucide-react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
type Language = "js" | "ruby" | "php" | "python" | "curl";
|
export type Language = "js" | "ruby" | "php" | "python" | "curl";
|
||||||
|
|
||||||
|
export type CodeBlock = {
|
||||||
|
language: Language;
|
||||||
|
title?: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
|
||||||
type CodeProps = {
|
type CodeProps = {
|
||||||
codeBlocks: {
|
codeBlocks: CodeBlock[];
|
||||||
language: Language;
|
|
||||||
code: string;
|
|
||||||
}[];
|
|
||||||
codeClassName?: string;
|
codeClassName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,7 +60,7 @@ export const Code: React.FC<CodeProps> = ({ codeBlocks, codeClassName }) => {
|
|||||||
value={block.language}
|
value={block.language}
|
||||||
className="data-[state=active]:bg-accent py-0.5 px-4 "
|
className="data-[state=active]:bg-accent py-0.5 px-4 "
|
||||||
>
|
>
|
||||||
{block.language}
|
{block.title || block.language}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
175
pnpm-lock.yaml
generated
175
pnpm-lock.yaml
generated
@@ -139,6 +139,9 @@ importers:
|
|||||||
'@unsend/ui':
|
'@unsend/ui':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/ui
|
version: link:../../packages/ui
|
||||||
|
bullmq:
|
||||||
|
specifier: ^5.8.2
|
||||||
|
version: 5.8.2
|
||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
@@ -148,6 +151,9 @@ importers:
|
|||||||
install:
|
install:
|
||||||
specifier: ^0.13.0
|
specifier: ^0.13.0
|
||||||
version: 0.13.0
|
version: 0.13.0
|
||||||
|
ioredis:
|
||||||
|
specifier: ^5.4.1
|
||||||
|
version: 5.4.1
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.359.0
|
specifier: ^0.359.0
|
||||||
version: 0.359.0(react@18.2.0)
|
version: 0.359.0(react@18.2.0)
|
||||||
@@ -196,6 +202,9 @@ importers:
|
|||||||
tldts:
|
tldts:
|
||||||
specifier: ^6.1.16
|
specifier: ^6.1.16
|
||||||
version: 6.1.16
|
version: 6.1.16
|
||||||
|
ua-parser-js:
|
||||||
|
specifier: ^1.0.38
|
||||||
|
version: 1.0.38
|
||||||
unsend:
|
unsend:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/sdk
|
version: link:../../packages/sdk
|
||||||
@@ -221,6 +230,9 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^18.2.19
|
specifier: ^18.2.19
|
||||||
version: 18.2.22
|
version: 18.2.22
|
||||||
|
'@types/ua-parser-js':
|
||||||
|
specifier: ^0.7.39
|
||||||
|
version: 0.7.39
|
||||||
'@typescript-eslint/eslint-plugin':
|
'@typescript-eslint/eslint-plugin':
|
||||||
specifier: ^7.1.1
|
specifier: ^7.1.1
|
||||||
version: 7.2.0(@typescript-eslint/parser@7.2.0)(eslint@8.57.0)(typescript@5.4.2)
|
version: 7.2.0(@typescript-eslint/parser@7.2.0)(eslint@8.57.0)(typescript@5.4.2)
|
||||||
@@ -2086,6 +2098,10 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
/@ioredis/commands@1.2.0:
|
||||||
|
resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@isaacs/cliui@8.0.2:
|
/@isaacs/cliui@8.0.2:
|
||||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2374,6 +2390,54 @@ packages:
|
|||||||
- debug
|
- debug
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3:
|
||||||
|
resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: false
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3:
|
||||||
|
resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: false
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3:
|
||||||
|
resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: false
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3:
|
||||||
|
resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: false
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3:
|
||||||
|
resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: false
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3:
|
||||||
|
resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: false
|
||||||
|
optional: true
|
||||||
|
|
||||||
/@next/env@14.1.4:
|
/@next/env@14.1.4:
|
||||||
resolution: {integrity: sha512-e7X7bbn3Z6DWnDi75UWn+REgAbLEqxI8Tq2pkFOFAMpWAWApz/YCUhtWMWn410h8Q2fYiYL7Yg5OlxMOCfFjJQ==}
|
resolution: {integrity: sha512-e7X7bbn3Z6DWnDi75UWn+REgAbLEqxI8Tq2pkFOFAMpWAWApz/YCUhtWMWn410h8Q2fYiYL7Yg5OlxMOCfFjJQ==}
|
||||||
dev: false
|
dev: false
|
||||||
@@ -4320,6 +4384,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==}
|
resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@types/ua-parser-js@0.7.39:
|
||||||
|
resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/unist@2.0.10:
|
/@types/unist@2.0.10:
|
||||||
resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==}
|
resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==}
|
||||||
|
|
||||||
@@ -5146,6 +5214,20 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/bullmq@5.8.2:
|
||||||
|
resolution: {integrity: sha512-V64+Nz28FO9YEEUiDonG5KFhjihedML/OxuHpB0D5vV8aWcF1ui/5nmjDcCIyx4EXiUUDDypSUotjzcYu8gkeg==}
|
||||||
|
dependencies:
|
||||||
|
cron-parser: 4.9.0
|
||||||
|
ioredis: 5.4.1
|
||||||
|
msgpackr: 1.10.2
|
||||||
|
node-abort-controller: 3.1.1
|
||||||
|
semver: 7.6.0
|
||||||
|
tslib: 2.6.2
|
||||||
|
uuid: 9.0.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
dev: false
|
||||||
|
|
||||||
/bundle-require@4.1.0(esbuild@0.19.12):
|
/bundle-require@4.1.0(esbuild@0.19.12):
|
||||||
resolution: {integrity: sha512-FeArRFM+ziGkRViKRnSTbHZc35dgmR9yNog05Kn0+ItI59pOAISGvnnIwW1WgFZQW59IxD9QpJnUPkdIPfZuXg==}
|
resolution: {integrity: sha512-FeArRFM+ziGkRViKRnSTbHZc35dgmR9yNog05Kn0+ItI59pOAISGvnnIwW1WgFZQW59IxD9QpJnUPkdIPfZuXg==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
@@ -5361,6 +5443,11 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/cluster-key-slot@1.1.2:
|
||||||
|
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/color-convert@1.9.3:
|
/color-convert@1.9.3:
|
||||||
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
|
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5618,7 +5705,6 @@ packages:
|
|||||||
optional: true
|
optional: true
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.2
|
ms: 2.1.2
|
||||||
dev: true
|
|
||||||
|
|
||||||
/decimal.js-light@2.5.1:
|
/decimal.js-light@2.5.1:
|
||||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||||
@@ -5695,6 +5781,11 @@ packages:
|
|||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/denque@2.1.0:
|
||||||
|
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||||
|
engines: {node: '>=0.10'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/depd@2.0.0:
|
/depd@2.0.0:
|
||||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -5722,7 +5813,6 @@ packages:
|
|||||||
/detect-libc@2.0.3:
|
/detect-libc@2.0.3:
|
||||||
resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
|
resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/detect-newline@4.0.1:
|
/detect-newline@4.0.1:
|
||||||
resolution: {integrity: sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog==}
|
resolution: {integrity: sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog==}
|
||||||
@@ -7690,6 +7780,23 @@ packages:
|
|||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/ioredis@5.4.1:
|
||||||
|
resolution: {integrity: sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==}
|
||||||
|
engines: {node: '>=12.22.0'}
|
||||||
|
dependencies:
|
||||||
|
'@ioredis/commands': 1.2.0
|
||||||
|
cluster-key-slot: 1.1.2
|
||||||
|
debug: 4.3.4
|
||||||
|
denque: 2.1.0
|
||||||
|
lodash.defaults: 4.2.0
|
||||||
|
lodash.isarguments: 3.1.0
|
||||||
|
redis-errors: 1.2.0
|
||||||
|
redis-parser: 3.0.0
|
||||||
|
standard-as-callback: 2.1.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
dev: false
|
||||||
|
|
||||||
/ip-regex@4.3.0:
|
/ip-regex@4.3.0:
|
||||||
resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==}
|
resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -8234,6 +8341,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/lodash.defaults@4.2.0:
|
||||||
|
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/lodash.isarguments@3.1.0:
|
||||||
|
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/lodash.merge@4.6.2:
|
/lodash.merge@4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
dev: true
|
dev: true
|
||||||
@@ -9021,12 +9136,33 @@ packages:
|
|||||||
|
|
||||||
/ms@2.1.2:
|
/ms@2.1.2:
|
||||||
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
|
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/ms@2.1.3:
|
/ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/msgpackr-extract@3.0.3:
|
||||||
|
resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==}
|
||||||
|
hasBin: true
|
||||||
|
requiresBuild: true
|
||||||
|
dependencies:
|
||||||
|
node-gyp-build-optional-packages: 5.2.2
|
||||||
|
optionalDependencies:
|
||||||
|
'@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3
|
||||||
|
'@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3
|
||||||
|
'@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3
|
||||||
|
dev: false
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/msgpackr@1.10.2:
|
||||||
|
resolution: {integrity: sha512-L60rsPynBvNE+8BWipKKZ9jHcSGbtyJYIwjRq0VrIvQ08cRjntGXJYW/tmciZ2IHWIY8WEW32Qa2xbh5+SKBZA==}
|
||||||
|
optionalDependencies:
|
||||||
|
msgpackr-extract: 3.0.3
|
||||||
|
dev: false
|
||||||
|
|
||||||
/mz@2.7.0:
|
/mz@2.7.0:
|
||||||
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -9193,6 +9329,10 @@ packages:
|
|||||||
'@types/nlcst': 1.0.4
|
'@types/nlcst': 1.0.4
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/node-abort-controller@3.1.1:
|
||||||
|
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/node-fetch@2.7.0:
|
/node-fetch@2.7.0:
|
||||||
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||||
engines: {node: 4.x || >=6.0.0}
|
engines: {node: 4.x || >=6.0.0}
|
||||||
@@ -9205,6 +9345,15 @@ packages:
|
|||||||
whatwg-url: 5.0.0
|
whatwg-url: 5.0.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/node-gyp-build-optional-packages@5.2.2:
|
||||||
|
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
|
||||||
|
hasBin: true
|
||||||
|
requiresBuild: true
|
||||||
|
dependencies:
|
||||||
|
detect-libc: 2.0.3
|
||||||
|
dev: false
|
||||||
|
optional: true
|
||||||
|
|
||||||
/node-releases@2.0.14:
|
/node-releases@2.0.14:
|
||||||
resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==}
|
resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==}
|
||||||
dev: true
|
dev: true
|
||||||
@@ -10253,6 +10402,18 @@ packages:
|
|||||||
victory-vendor: 36.9.2
|
victory-vendor: 36.9.2
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/redis-errors@1.2.0:
|
||||||
|
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/redis-parser@3.0.0:
|
||||||
|
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
dependencies:
|
||||||
|
redis-errors: 1.2.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
/reflect.getprototypeof@1.0.5:
|
/reflect.getprototypeof@1.0.5:
|
||||||
resolution: {integrity: sha512-62wgfC8dJWrmxv44CA36pLDnP6KKl3Vhxb7PL+8+qrrFMMoJij4vgiMP8zV4O8+CBMXY1mHxI5fITGHXFHVmQQ==}
|
resolution: {integrity: sha512-62wgfC8dJWrmxv44CA36pLDnP6KKl3Vhxb7PL+8+qrrFMMoJij4vgiMP8zV4O8+CBMXY1mHxI5fITGHXFHVmQQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -10900,6 +11061,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/standard-as-callback@2.1.0:
|
||||||
|
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/statuses@2.0.1:
|
/statuses@2.0.1:
|
||||||
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -11452,6 +11617,10 @@ packages:
|
|||||||
hasBin: true
|
hasBin: true
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/ua-parser-js@1.0.38:
|
||||||
|
resolution: {integrity: sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/unbox-primitive@1.0.2:
|
/unbox-primitive@1.0.2:
|
||||||
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
|
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@@ -23,8 +23,6 @@
|
|||||||
"GITHUB_SECRET",
|
"GITHUB_SECRET",
|
||||||
"AWS_SECRET_KEY",
|
"AWS_SECRET_KEY",
|
||||||
"AWS_ACCESS_KEY",
|
"AWS_ACCESS_KEY",
|
||||||
"APP_URL",
|
|
||||||
"SNS_TOPIC",
|
|
||||||
"NEXTAUTH_SECRET",
|
"NEXTAUTH_SECRET",
|
||||||
"NODE_ENV",
|
"NODE_ENV",
|
||||||
"VERCEL_URL",
|
"VERCEL_URL",
|
||||||
|
Reference in New Issue
Block a user