├── .dockerignore
├── .github
├── FUNDING.yml
└── workflows
│ ├── docker-build-canary.yml
│ └── docker-build-prod.yml
├── .gitignore
├── .yarn
└── releases
│ └── yarn-4.3.0.cjs
├── .yarnrc.yml
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── assets
└── card.png
├── biome.json
├── deployment
├── entry.sh
├── nginx.conf
└── replace-variables.sh
├── docker-compose.dev.yml
├── lerna.json
├── package.json
├── packages
├── api
│ ├── .env.example
│ ├── package.json
│ ├── src
│ │ ├── app.ts
│ │ ├── app
│ │ │ ├── constants.ts
│ │ │ └── cron.ts
│ │ ├── controllers
│ │ │ ├── Auth.ts
│ │ │ ├── Health.ts
│ │ │ ├── Identities.ts
│ │ │ ├── Memberships.ts
│ │ │ ├── Projects.ts
│ │ │ ├── Tasks.ts
│ │ │ ├── Users.ts
│ │ │ ├── Webhooks
│ │ │ │ ├── Incoming
│ │ │ │ │ ├── SNS.ts
│ │ │ │ │ └── index.ts
│ │ │ │ └── index.ts
│ │ │ └── v1
│ │ │ │ ├── Actions.ts
│ │ │ │ ├── Campaigns.ts
│ │ │ │ ├── Contacts.ts
│ │ │ │ ├── Events.ts
│ │ │ │ ├── Templates.ts
│ │ │ │ └── index.ts
│ │ ├── database
│ │ │ └── prisma.ts
│ │ ├── exceptions
│ │ │ └── index.ts
│ │ ├── middleware
│ │ │ └── auth.ts
│ │ ├── services
│ │ │ ├── ActionService.ts
│ │ │ ├── AuthService.ts
│ │ │ ├── CampaignService.ts
│ │ │ ├── ContactService.ts
│ │ │ ├── EmailService.ts
│ │ │ ├── EventService.ts
│ │ │ ├── MembershipService.ts
│ │ │ ├── ProjectService.ts
│ │ │ ├── TemplateService.ts
│ │ │ ├── UserService.ts
│ │ │ ├── keys.ts
│ │ │ └── redis.ts
│ │ └── util
│ │ │ ├── hash.ts
│ │ │ ├── ses.ts
│ │ │ └── tokens.ts
│ └── tsconfig.json
├── dashboard
│ ├── .env.example
│ ├── next-env.d.ts
│ ├── next.config.js
│ ├── package.json
│ ├── postcss.config.js
│ ├── public
│ │ ├── assets
│ │ │ ├── card.png
│ │ │ ├── logo.png
│ │ │ └── shared.svg
│ │ └── favicon
│ │ │ ├── android-chrome-192x192.png
│ │ │ ├── android-chrome-512x512.png
│ │ │ ├── apple-touch-icon.png
│ │ │ ├── browserconfig.xml
│ │ │ ├── favicon-16x16.png
│ │ │ ├── favicon-32x32.png
│ │ │ ├── favicon.ico
│ │ │ ├── mstile-150x150.png
│ │ │ ├── safari-pinned-tab.svg
│ │ │ └── site.webmanifest
│ ├── src
│ │ ├── components
│ │ │ ├── Alert
│ │ │ │ ├── Alert.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── Badge
│ │ │ │ ├── Badge.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── Card
│ │ │ │ ├── Card.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── CodeBlock
│ │ │ │ ├── CodeBlock.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── Input
│ │ │ │ ├── Dropdown
│ │ │ │ │ ├── Dropdown.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── Input
│ │ │ │ │ ├── Input.tsx
│ │ │ │ │ └── index.ts
│ │ │ │ ├── MarkdownEditor
│ │ │ │ │ ├── Editor.tsx
│ │ │ │ │ ├── extensions
│ │ │ │ │ │ ├── Button.tsx
│ │ │ │ │ │ ├── ColorSelector.tsx
│ │ │ │ │ │ ├── EditorBubbleMenu.tsx
│ │ │ │ │ │ ├── MetadataSuggestion
│ │ │ │ │ │ │ ├── MetadataSuggestion.tsx
│ │ │ │ │ │ │ ├── SuggestionList.tsx
│ │ │ │ │ │ │ └── Suggestions.ts
│ │ │ │ │ │ ├── NodeSelector.tsx
│ │ │ │ │ │ ├── Progress.tsx
│ │ │ │ │ │ └── Slash.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── MultiselectDropdown
│ │ │ │ │ ├── MultiselectDropdown.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── Toggle
│ │ │ │ │ ├── Toggle.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── Navigation
│ │ │ │ ├── AnalyticsTabs
│ │ │ │ │ ├── AnalyticsTabs.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── DeveloperTabs
│ │ │ │ │ ├── DeveloperTabs.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── ProjectSelector
│ │ │ │ │ ├── ProjectSelector.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── SettingTabs
│ │ │ │ │ ├── SettingTabs.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── Sidebar
│ │ │ │ │ ├── Sidebar.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── Tabs
│ │ │ │ │ ├── Tabs.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── Overlay
│ │ │ │ ├── Modal
│ │ │ │ │ ├── Modal.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── Skeleton
│ │ │ │ ├── Skeleton.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── Table
│ │ │ │ ├── Table.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── Utility
│ │ │ │ ├── Empty
│ │ │ │ │ ├── Empty.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── FullscreenLoader
│ │ │ │ │ ├── FullscreenLoader.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── ProgressBar
│ │ │ │ │ ├── ProgressBar.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── Redirect
│ │ │ │ │ ├── Redirect.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── Tooltip
│ │ │ │ │ ├── Tooltip.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ └── index.tsx
│ │ │ └── index.ts
│ │ ├── layouts
│ │ │ ├── Dashboard.tsx
│ │ │ └── index.ts
│ │ ├── lib
│ │ │ ├── atoms
│ │ │ │ └── project.ts
│ │ │ ├── constants.ts
│ │ │ ├── hooks
│ │ │ │ ├── actions.ts
│ │ │ │ ├── analytics.ts
│ │ │ │ ├── campaigns.ts
│ │ │ │ ├── contacts.ts
│ │ │ │ ├── emails.ts
│ │ │ │ ├── events.ts
│ │ │ │ ├── projects.ts
│ │ │ │ ├── templates.ts
│ │ │ │ └── users.ts
│ │ │ └── network.ts
│ │ └── pages
│ │ │ ├── _app.tsx
│ │ │ ├── _document.tsx
│ │ │ ├── actions
│ │ │ ├── [id].tsx
│ │ │ ├── index.tsx
│ │ │ └── new.tsx
│ │ │ ├── analytics
│ │ │ ├── clicks.tsx
│ │ │ └── index.tsx
│ │ │ ├── api
│ │ │ └── health.ts
│ │ │ ├── auth
│ │ │ ├── login.tsx
│ │ │ ├── logout.tsx
│ │ │ ├── reset.tsx
│ │ │ └── signup.tsx
│ │ │ ├── campaigns
│ │ │ ├── [id].tsx
│ │ │ ├── index.tsx
│ │ │ └── new.tsx
│ │ │ ├── contacts
│ │ │ ├── [id].tsx
│ │ │ └── index.tsx
│ │ │ ├── events
│ │ │ └── index.tsx
│ │ │ ├── index.tsx
│ │ │ ├── manage
│ │ │ └── [id].tsx
│ │ │ ├── new.tsx
│ │ │ ├── onboarding
│ │ │ ├── actions.tsx
│ │ │ ├── index.tsx
│ │ │ └── transactional.tsx
│ │ │ ├── settings
│ │ │ ├── account.tsx
│ │ │ ├── api.tsx
│ │ │ ├── identity.tsx
│ │ │ ├── index.tsx
│ │ │ ├── members.tsx
│ │ │ └── project.tsx
│ │ │ ├── subscribe
│ │ │ └── [id].tsx
│ │ │ ├── templates
│ │ │ ├── [id].tsx
│ │ │ ├── index.tsx
│ │ │ └── new.tsx
│ │ │ └── unsubscribe
│ │ │ └── [id].tsx
│ ├── styles
│ │ └── index.css
│ ├── tailwind.config.js
│ └── tsconfig.json
└── shared
│ ├── package.json
│ ├── src
│ └── index.ts
│ └── tsconfig.json
├── prisma
├── .env.example
├── migrations
│ ├── 20240719085125_init
│ │ └── migration.sql
│ ├── 20240924145046_template_sender_overwrite
│ │ └── migration.sql
│ └── migration_lock.toml
└── schema.prisma
├── tools
└── preinstall.js
├── tsconfig.json
└── yarn.lock
/.dockerignore:
--------------------------------------------------------------------------------
1 | .env
2 | .next/
3 | .github/
4 | dist/
5 | assets/
6 | node_modules/
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [ driaug ]
2 |
--------------------------------------------------------------------------------
/.github/workflows/docker-build-canary.yml:
--------------------------------------------------------------------------------
1 | name: Build and Push Docker image (Canary)
2 |
3 | on:
4 | push:
5 | branches:
6 | - canary
7 |
8 | jobs:
9 | docker:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Check out the repo
13 | uses: actions/checkout@v2
14 |
15 | - name: Set up Docker Buildx
16 | uses: docker/setup-buildx-action@v1
17 |
18 | - name: Log in to Docker Hub
19 | uses: docker/login-action@v1
20 | with:
21 | username: ${{ secrets.DOCKER_USERNAME }}
22 | password: ${{ secrets.DOCKER_PASSWORD }}
23 |
24 | - name: Build and push
25 | uses: docker/build-push-action@v2
26 | with:
27 | context: .
28 | file: ./Dockerfile
29 | push: true
30 | tags: |
31 | driaug/plunk:canary
32 | platforms: linux/amd64,linux/arm64
--------------------------------------------------------------------------------
/.github/workflows/docker-build-prod.yml:
--------------------------------------------------------------------------------
1 | name: Build and Push Docker image (Production)
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | docker:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Check out the repo
13 | uses: actions/checkout@v2
14 |
15 | - name: Set up Docker Buildx
16 | uses: docker/setup-buildx-action@v1
17 |
18 | - name: Get version from package.json
19 | id: get_version
20 | run: echo "VERSION=$(jq -r .version package.json)" >> $GITHUB_ENV
21 |
22 | - name: Log in to Docker Hub
23 | uses: docker/login-action@v1
24 | with:
25 | username: ${{ secrets.DOCKER_USERNAME }}
26 | password: ${{ secrets.DOCKER_PASSWORD }}
27 |
28 | - name: Build and push
29 | uses: docker/build-push-action@v2
30 | with:
31 | context: .
32 | file: ./Dockerfile
33 | push: true
34 | tags: |
35 | driaug/plunk:${{ env.VERSION }}
36 | driaug/plunk:latest
37 | platforms: linux/amd64,linux/arm64
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | .idea
4 | .vscode
5 | *.log
6 | .env
7 | .tscache
8 | .next
9 | .out
10 | build
11 | .DS_Store
12 | .yarn/install-state.gz
13 |
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nodeLinker: node-modules
2 |
3 | yarnPath: .yarn/releases/yarn-4.3.0.cjs
4 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | You can greatly support Plunk by contributing to this repository.
4 |
5 | Support can be asked in the `#contributions` channel of the [Plunk Discord server](https://useplunk.com/discord)
6 |
7 | ### 1. Requirements
8 |
9 | - Docker needs to be [installed](https://docs.docker.com/engine/install/) on your system.
10 |
11 | ### 2. Install dependencies
12 |
13 | - Run `yarn install` to install the dependencies.
14 |
15 | ### 3. Set your environment variables
16 |
17 | - Copy the `.env.example` files in the `api`, `dashboard` and `prisma` folder to `.env` in their respective folders.
18 | - Set AWS credentials in the `api` `.env` file.
19 |
20 | ### 4. Start resources
21 |
22 | - Run `yarn services:up` to start a local database and a local redis server.
23 | - Run `yarn migrate` to apply the migrations to the database.
24 | - Run `yarn build:shared` to build the shared package.
25 |
26 |
27 | - Run `yarn dev:api` to start the API server.
28 | - Run `yarn dev:dashboard` to start the dashboard server.
29 |
30 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Base Stage
2 | FROM node:20-alpine3.20 AS base
3 |
4 | WORKDIR /app
5 |
6 | COPY . .
7 |
8 | ARG NEXT_PUBLIC_API_URI=PLUNK_API_URI
9 |
10 | RUN yarn install --network-timeout 1000000
11 | RUN yarn build:shared
12 | RUN yarn workspace @plunk/api build
13 | RUN yarn workspace @plunk/dashboard build
14 |
15 | # Final Stage
16 | FROM node:20-alpine3.20
17 |
18 | WORKDIR /app
19 |
20 | RUN apk add --no-cache bash nginx
21 |
22 | COPY --from=base /app/packages/api/dist /app/packages/api/
23 | COPY --from=base /app/packages/dashboard/.next /app/packages/dashboard/.next
24 | COPY --from=base /app/packages/dashboard/public /app/packages/dashboard/public
25 | COPY --from=base /app/node_modules /app/node_modules
26 | COPY --from=base /app/packages/shared /app/packages/shared
27 | COPY --from=base /app/prisma /app/prisma
28 | COPY deployment/nginx.conf /etc/nginx/nginx.conf
29 | COPY deployment/entry.sh deployment/replace-variables.sh /app/
30 |
31 | RUN chmod +x /app/entry.sh /app/replace-variables.sh
32 |
33 | EXPOSE 3000 4000 5000
34 |
35 | CMD ["sh", "/app/entry.sh"]
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 |
Plunk
4 |
5 |
6 | The Open-Source Email Platform
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | ## Introduction
18 |
19 | Plunk is an open-source email platform built on top of AWS SES. It allows you to easily send emails from your
20 | applications.
21 | It can be considered as a self-hosted alternative to services
22 | like [SendGrid](https://sendgrid.com/), [Resend](https://resend.com) or [Mailgun](https://www.mailgun.com/).
23 |
24 | ## Features
25 |
26 | - **Transactional Emails**: Send emails straight from your API
27 | - **Automations**: Create automations based on user actions
28 | - **Broadcasts**: Send newsletters and product updates to big audiences
29 |
30 | ## Self-hosting Plunk
31 |
32 | The easiest way to self-host Plunk is by using the `driaug/plunk` Docker image.
33 | You can pull the latest image from [Docker Hub](https://hub.docker.com/r/driaug/plunk/).
34 |
35 | A complete guide on how to deploy Plunk can be found in
36 | the [documentation](https://docs.useplunk.com/getting-started/self-hosting).
37 |
38 | ## Contributing
39 |
40 | You are welcome to contribute to Plunk. You can find a guide on how to contribute in [CONTRIBUTING.md](CONTRIBUTING.md).
41 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/assets/card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/useplunk/plunk/d97353cee68e57e408c5f4233d1ce939c711ded9/assets/card.png
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json",
3 | "organizeImports": {
4 | "enabled": true
5 | },
6 | "linter": {
7 | "enabled": true,
8 | "rules": {
9 | "recommended": true,
10 | "a11y": {
11 | "useKeyWithClickEvents": "off",
12 | "noSvgWithoutTitle": "off",
13 | "useButtonType": "off"
14 | },
15 | "complexity": {
16 | "noForEach": "off",
17 | "noStaticOnlyClass": "off"
18 | }
19 | }
20 | },
21 | "formatter": {
22 | "indentStyle": "tab",
23 | "indentWidth": 1,
24 | "lineWidth": 120
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/deployment/entry.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | echo "Starting Prisma migrations..."
4 | npx prisma migrate deploy
5 | echo "Prisma migrations completed."
6 |
7 | sh replace-variables.sh &&
8 |
9 | nginx &
10 |
11 | echo "Starting the API server..."
12 | node packages/api/app.js &
13 | echo "API server started in the background."
14 |
15 | echo "Starting the Dashboard..."
16 | cd packages/dashboard
17 | npx next start -p 5000 -H 0.0.0.0
18 | echo "Dashboard started."
19 |
--------------------------------------------------------------------------------
/deployment/nginx.conf:
--------------------------------------------------------------------------------
1 | events {
2 | worker_connections 1024;
3 | }
4 |
5 | http {
6 | server {
7 | listen 3000;
8 |
9 | location /api/ {
10 | proxy_pass http://127.0.0.1:4000/;
11 | proxy_set_header Host $host;
12 | proxy_http_version 1.1;
13 | proxy_set_header Upgrade $http_upgrade;
14 | proxy_set_header Connection "upgrade";
15 | proxy_set_header X-Real-IP $remote_addr;
16 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
17 | proxy_set_header X-Forwarded-Proto $scheme;
18 | }
19 |
20 | location / {
21 | proxy_pass http://127.0.0.1:5000;
22 | proxy_set_header Host $host;
23 | proxy_http_version 1.1;
24 | proxy_set_header Upgrade $http_upgrade;
25 | proxy_set_header Connection "upgrade";
26 | proxy_set_header X-Real-IP $remote_addr;
27 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
28 | proxy_set_header X-Forwarded-Proto $scheme;
29 | }
30 | }
31 | }
--------------------------------------------------------------------------------
/deployment/replace-variables.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | echo "Baking Environment Variables..."
4 |
5 | if [ -z "${API_URI}" ]; then
6 | echo "API_URI is not set. Exiting..."
7 | exit 1
8 | fi
9 |
10 | if [ -z "${AWS_REGION}" ]; then
11 | echo "AWS_REGION is not set. Exiting..."
12 | exit 1
13 | fi
14 |
15 | # Process each directory that might contain JS files
16 | for dir in "/app/packages/dashboard/public" "/app/packages/dashboard/.next"; do
17 | if [ -d "$dir" ]; then
18 | # Find all JS files and process them
19 | find "$dir" -type f -name "*.js" -o -name "*.mjs" | while read -r file; do
20 | if [ -f "$file" ]; then
21 | # Replace environment variables
22 | sed -i "s|PLUNK_API_URI|${API_URI}|g" "$file"
23 | sed -i "s|PLUNK_AWS_REGION|${AWS_REGION}|g" "$file"
24 | echo "Processed: $file"
25 | fi
26 | done
27 | else
28 | echo "Warning: Directory $dir does not exist, skipping..."
29 | fi
30 | done
31 |
32 | echo "Environment Variables Baked."
33 |
--------------------------------------------------------------------------------
/docker-compose.dev.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | db:
4 | image: postgres
5 | ports:
6 | - 55432:5432
7 | environment:
8 | POSTGRES_PASSWORD: postgres
9 | POSTGRES_USER: postgres
10 | POSTGRES_DB: postgres
11 |
12 | redis:
13 | image: redis
14 | ports:
15 | - 56379:6379
--------------------------------------------------------------------------------
/lerna.json:
--------------------------------------------------------------------------------
1 | {
2 | "npmClient": "yarn",
3 | "packages": [
4 | "packages/*"
5 | ],
6 | "version": "1.0.0"
7 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "plunk",
3 | "version": "1.0.13",
4 | "private": true,
5 | "license": "agpl-3.0",
6 | "workspaces": {
7 | "packages": [
8 | "packages/*"
9 | ]
10 | },
11 | "engines": {
12 | "npm": ">=6.14.x",
13 | "yarn": "4.3.x",
14 | "node": ">=18.x"
15 | },
16 | "devDependencies": {
17 | "@biomejs/biome": "^1.8.3",
18 | "lerna": "^8.1.6",
19 | "prisma": "^5.17.0",
20 | "rimraf": "^5.0.9"
21 | },
22 | "dependencies": {
23 | "@prisma/client": "^5.17.0"
24 | },
25 | "scripts": {
26 | "dev:api": "yarn workspace @plunk/api dev",
27 | "dev:dashboard": "yarn workspace @plunk/dashboard dev",
28 | "dev:shared": "yarn workspace @plunk/shared dev",
29 | "build:api": "yarn build:shared && yarn workspace @plunk/api build",
30 | "build:dashboard": "yarn build:shared && yarn workspace @plunk/dashboard build",
31 | "build:shared": "yarn generate && yarn workspace @plunk/shared build",
32 | "clean": "rimraf node_modules yarn.lock && yarn add lerna -DW && lerna run clean",
33 | "preinstall": "node tools/preinstall.js",
34 | "migrate": "prisma migrate dev",
35 | "migrate:deploy": "prisma migrate deploy",
36 | "generate": "prisma generate",
37 | "services:up": "docker compose -f docker-compose.dev.yml up -d",
38 | "services:down": "docker compose -f docker-compose.dev.yml down"
39 | },
40 | "packageManager": "yarn@4.3.0"
41 | }
42 |
--------------------------------------------------------------------------------
/packages/api/.env.example:
--------------------------------------------------------------------------------
1 | # ENV
2 | JWT_SECRET=mysupersecretJWTsecret
3 | REDIS_URL=redis://127.0.0.1:56379
4 | DATABASE_URL=postgresql://postgres:postgres@localhost:55432/postgres
5 | DISABLE_SIGNUPS=false
6 |
7 | # AWS
8 | AWS_REGION=
9 | AWS_ACCESS_KEY_ID=
10 | AWS_SECRET_ACCESS_KEY=
11 | AWS_SES_CONFIGURATION_SET=
--------------------------------------------------------------------------------
/packages/api/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@plunk/api",
3 | "version": "1.0.0",
4 | "main": "dist/index.js",
5 | "private": true,
6 | "scripts": {
7 | "dev": "cross-env NODE_ENV=development ts-node-dev --transpile-only --exit-child src/app.ts",
8 | "start": "node ./dist/app.js",
9 | "build": "tsc",
10 | "clean": "rimraf node_modules dist .turbo"
11 | },
12 | "devDependencies": {
13 | "@types/bcrypt": "^5.0.2",
14 | "@types/compression": "^1.7.5",
15 | "@types/cookie-parser": "^1.4.7",
16 | "@types/cors": "^2.8.17",
17 | "@types/express": "^4.17.21",
18 | "@types/ioredis": "^5.0.0",
19 | "@types/jsonwebtoken": "^9.0.6",
20 | "@types/mjml": "^4.7.4",
21 | "@types/morgan": "^1.9.9",
22 | "@types/node-cron": "^3.0.11",
23 | "@types/signale": "^1.4.7",
24 | "cross-env": "^7.0.3",
25 | "prisma": "^5.17.0",
26 | "ts-node-dev": "^2.0.0",
27 | "typescript": "^5.5.3"
28 | },
29 | "dependencies": {
30 | "@aws-sdk/client-cloudfront": "^3.616.0",
31 | "@aws-sdk/client-ses": "^3.616.0",
32 | "@overnightjs/core": "^1.7.6",
33 | "@plunk/shared": "^1.0.0",
34 | "@prisma/client": "^5.17.0",
35 | "bcrypt": "^5.1.1",
36 | "body-parser": "^1.20.2",
37 | "compression": "^1.7.4",
38 | "cookie-parser": "^1.4.6",
39 | "cors": "^2.8.5",
40 | "dotenv": "^16.4.5",
41 | "express": "^4.19.2",
42 | "express-async-errors": "^3.1.1",
43 | "helmet": "^7.1.0",
44 | "ioredis": "^5.4.1",
45 | "jsonwebtoken": "^9.0.2",
46 | "mjml": "^4.15.3",
47 | "morgan": "^1.10.0",
48 | "node-cron": "^3.0.3",
49 | "signale": "^1.4.0"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/packages/api/src/app.ts:
--------------------------------------------------------------------------------
1 | import "dotenv/config";
2 | import "express-async-errors";
3 |
4 | import { STATUS_CODES } from "node:http";
5 | import { Server } from "@overnightjs/core";
6 | import compression from "compression";
7 | import cookies from "cookie-parser";
8 | import cors from "cors";
9 | import { type NextFunction, type Request, type Response, json } from "express";
10 | import helmet from "helmet";
11 | import morgan from "morgan";
12 | import signale from "signale";
13 | import { APP_URI, NODE_ENV } from "./app/constants";
14 | import { task } from "./app/cron";
15 | import { Auth } from "./controllers/Auth";
16 | import { Identities } from "./controllers/Identities";
17 | import { Memberships } from "./controllers/Memberships";
18 | import { Projects } from "./controllers/Projects";
19 | import { Tasks } from "./controllers/Tasks";
20 | import { Users } from "./controllers/Users";
21 | import { Webhooks } from "./controllers/Webhooks";
22 | import { V1 } from "./controllers/v1";
23 | import { prisma } from "./database/prisma";
24 | import { HttpException } from "./exceptions";
25 | import { Health } from "./controllers/Health";
26 |
27 | const server = new (class extends Server {
28 | public constructor() {
29 | super();
30 |
31 | // Set the content-type to JSON for any request coming from AWS SNS
32 | this.app.use((req, res, next) => {
33 | if (req.get("x-amz-sns-message-type")) {
34 | req.headers["content-type"] = "application/json";
35 | }
36 | next();
37 | });
38 |
39 | this.app.use(
40 | compression({
41 | threshold: 0,
42 | }),
43 | );
44 |
45 | // Parse the rest of our application as json
46 | this.app.use(json({ limit: "50mb" }));
47 | this.app.use(cookies());
48 | this.app.use(helmet());
49 |
50 | this.app.use(["/v1", "/v1/track", "/v1/send"], (req, res, next) => {
51 | res.set({ "Access-Control-Allow-Origin": "*" });
52 | next();
53 | });
54 |
55 | this.app.use(
56 | cors({
57 | origin: [APP_URI],
58 | credentials: true,
59 | }),
60 | );
61 |
62 | this.app.use(morgan(NODE_ENV === "development" ? "dev" : "short"));
63 |
64 | this.addControllers([
65 | new Auth(),
66 | new Users(),
67 | new Projects(),
68 | new Memberships(),
69 | new Webhooks(),
70 | new Identities(),
71 | new Tasks(),
72 | new V1(),
73 | new Health(),
74 | ]);
75 |
76 | this.app.use("*", () => {
77 | throw new HttpException(404, "Unknown route");
78 | });
79 | }
80 | })();
81 |
82 | server.app.use((req, res, next) => {
83 | console.log(`Incoming request: ${req.method} ${req.path}`);
84 | next();
85 | });
86 |
87 | server.app.use((error: Error, req: Request, res: Response, _next: NextFunction) => {
88 | const code = error instanceof HttpException ? error.code : 500;
89 |
90 | if (NODE_ENV !== "development") {
91 | signale.error(error);
92 | }
93 |
94 | res.status(code).json({
95 | code,
96 | error: STATUS_CODES[code],
97 | message: error.message,
98 | time: Date.now(),
99 | });
100 | });
101 |
102 | void prisma.$connect().then(() => {
103 | server.app.listen(4000, () => {
104 | task.start();
105 |
106 | signale.success("[HTTPS] Ready on", 4000);
107 | });
108 | });
109 |
--------------------------------------------------------------------------------
/packages/api/src/app/constants.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Safely parse environment variables
3 | * @param key The key
4 | * @param defaultValue An optional default value if the environment variable does not exist
5 | */
6 | export function validateEnv(key: keyof NodeJS.ProcessEnv, defaultValue?: T): T {
7 | const value = process.env[key] as T | undefined;
8 |
9 | if (!value) {
10 | if (typeof defaultValue !== "undefined") {
11 | return defaultValue;
12 | }
13 | throw new Error(`${key} is not defined in environment variables`);
14 | }
15 |
16 | return value;
17 | }
18 |
19 | // ENV
20 | export const JWT_SECRET = validateEnv("JWT_SECRET");
21 | export const NODE_ENV = validateEnv<"development" | "production">("NODE_ENV", "production");
22 |
23 | export const REDIS_URL = validateEnv("REDIS_URL");
24 | export const DISABLE_SIGNUPS = validateEnv("DISABLE_SIGNUPS", "false").toLowerCase() === "true";
25 |
26 | // URLs
27 | export const API_URI = validateEnv("API_URI", "http://localhost:4000");
28 | export const APP_URI = validateEnv("APP_URI", "http://localhost:3000");
29 |
30 | if (!API_URI.startsWith("http")) {
31 | throw new Error("API_URI must start with 'http'");
32 | }
33 |
34 | if (!APP_URI.startsWith("http")) {
35 | throw new Error("APP_URI must start with 'http'");
36 | }
37 |
38 | // AWS
39 | export const AWS_REGION = validateEnv("AWS_REGION");
40 | export const AWS_ACCESS_KEY_ID = validateEnv("AWS_ACCESS_KEY_ID");
41 | export const AWS_SECRET_ACCESS_KEY = validateEnv("AWS_SECRET_ACCESS_KEY");
42 | export const AWS_SES_CONFIGURATION_SET = validateEnv("AWS_SES_CONFIGURATION_SET");
43 |
--------------------------------------------------------------------------------
/packages/api/src/app/cron.ts:
--------------------------------------------------------------------------------
1 | import cron from "node-cron";
2 | import signale from "signale";
3 | import { API_URI } from "./constants";
4 |
5 | export const task = cron.schedule("* * * * *", () => {
6 | signale.info("Running scheduled tasks");
7 | try {
8 | void fetch(`${API_URI}/tasks`, {
9 | method: "POST",
10 | });
11 | } catch (e) {
12 | signale.error("Failed to run scheduled tasks. Please check the error below");
13 | console.error(e);
14 | }
15 |
16 | signale.info("Updating verified identities");
17 | try {
18 | void fetch(`${API_URI}/identities/update`, {
19 | method: "POST",
20 | });
21 | } catch (e) {
22 | signale.error("Failed to update verified identities. Please check the error below");
23 | console.error(e);
24 | }
25 | });
26 |
--------------------------------------------------------------------------------
/packages/api/src/controllers/Auth.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get, Post } from "@overnightjs/core";
2 | import { UserSchemas, UtilitySchemas } from "@plunk/shared";
3 | import type { Request, Response } from "express";
4 | import { DISABLE_SIGNUPS } from "../app/constants";
5 | import { prisma } from "../database/prisma";
6 | import { NotAllowed, NotFound } from "../exceptions";
7 | import { jwt } from "../middleware/auth";
8 | import { AuthService } from "../services/AuthService";
9 | import { UserService } from "../services/UserService";
10 | import { Keys } from "../services/keys";
11 | import { REDIS_ONE_MINUTE, redis } from "../services/redis";
12 | import { createHash } from "../util/hash";
13 |
14 | @Controller("auth")
15 | export class Auth {
16 | @Post("login")
17 | public async login(req: Request, res: Response) {
18 | const { email, password } = UserSchemas.credentials.parse(req.body);
19 |
20 | const user = await UserService.email(email);
21 |
22 | if (!user) {
23 | return res.json({ success: false, data: "Incorrect email or password" });
24 | }
25 |
26 | if (!user.password) {
27 | return res.json({
28 | success: "redirect",
29 | redirect: `/auth/reset?id=${user.id}`,
30 | });
31 | }
32 |
33 | const verified = await AuthService.verifyCredentials(email, password);
34 |
35 | if (!verified) {
36 | return res.json({ success: false, data: "Incorrect email or password" });
37 | }
38 |
39 | await redis.set(Keys.User.id(user.id), JSON.stringify(user), "EX", REDIS_ONE_MINUTE * 60);
40 |
41 | const token = jwt.sign(user.id);
42 | const cookie = UserService.cookieOptions();
43 |
44 | return res
45 | .cookie(UserService.COOKIE_NAME, token, cookie)
46 | .json({ success: true, data: { id: user.id, email: user.email } });
47 | }
48 |
49 | @Post("signup")
50 | public async signup(req: Request, res: Response) {
51 | if (DISABLE_SIGNUPS) {
52 | return res.json({
53 | success: false,
54 | data: "Signups are currently disabled",
55 | });
56 | }
57 |
58 | const { email, password } = UserSchemas.credentials.parse(req.body);
59 |
60 | const user = await UserService.email(email);
61 |
62 | if (user) {
63 | return res.json({
64 | success: false,
65 | data: "That email is already associated with another user",
66 | });
67 | }
68 |
69 | const created_user = await prisma.user.create({
70 | data: {
71 | email,
72 | password: await createHash(password),
73 | },
74 | });
75 |
76 | await redis.set(Keys.User.id(created_user.id), JSON.stringify(created_user), "EX", REDIS_ONE_MINUTE * 60);
77 |
78 | const token = jwt.sign(created_user.id);
79 | const cookie = UserService.cookieOptions();
80 |
81 | return res.cookie(UserService.COOKIE_NAME, token, cookie).json({
82 | success: true,
83 | data: { id: created_user.id, email: created_user.email },
84 | });
85 | }
86 |
87 | @Post("reset")
88 | public async reset(req: Request, res: Response) {
89 | const { id, password } = UtilitySchemas.id.merge(UserSchemas.credentials.pick({ password: true })).parse(req.body);
90 |
91 | const user = await UserService.id(id);
92 |
93 | if (!user) {
94 | throw new NotFound("user");
95 | }
96 |
97 | if (user.password) {
98 | throw new NotAllowed();
99 | }
100 |
101 | await prisma.user.update({
102 | where: { id },
103 | data: { password: await createHash(password) },
104 | });
105 |
106 | await redis.del(Keys.User.id(user.id));
107 | await redis.del(Keys.User.email(user.email));
108 |
109 | return res.json({ success: true });
110 | }
111 |
112 | @Get("logout")
113 | public logout(req: Request, res: Response) {
114 | res.cookie(UserService.COOKIE_NAME, "", UserService.cookieOptions(new Date()));
115 | return res.json(true);
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/packages/api/src/controllers/Health.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get } from "@overnightjs/core";
2 | import type { Request, Response } from "express";
3 |
4 | @Controller("health")
5 | export class Health {
6 | @Get("")
7 | public async health(req: Request, res: Response) {
8 | return res.json({ success: true });
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/packages/api/src/controllers/Identities.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get, Middleware, Post } from "@overnightjs/core";
2 | import { IdentitySchemas, UtilitySchemas } from "@plunk/shared";
3 | import type { Request, Response } from "express";
4 | import signale from "signale";
5 | import { prisma } from "../database/prisma";
6 | import { NotFound } from "../exceptions";
7 | import { type IJwt, isAuthenticated } from "../middleware/auth";
8 | import { ProjectService } from "../services/ProjectService";
9 | import { Keys } from "../services/keys";
10 | import { redis } from "../services/redis";
11 | import { getIdentities, getIdentityVerificationAttributes, ses, verifyIdentity } from "../util/ses";
12 |
13 | @Controller("identities")
14 | export class Identities {
15 | @Get("id/:id")
16 | @Middleware([isAuthenticated])
17 | public async getVerification(req: Request, res: Response) {
18 | const { id } = UtilitySchemas.id.parse(req.params);
19 |
20 | const project = await ProjectService.id(id);
21 |
22 | if (!project) {
23 | throw new NotFound("project");
24 | }
25 |
26 | if (!project.email) {
27 | return res.status(200).json({ success: false });
28 | }
29 |
30 | const attributes = await getIdentityVerificationAttributes(project.email);
31 |
32 | if (attributes.status === "Success" && !project.verified) {
33 | await prisma.project.update({ where: { id }, data: { verified: true } });
34 |
35 | await redis.del(Keys.Project.id(project.id));
36 | await redis.del(Keys.Project.secret(project.secret));
37 | await redis.del(Keys.Project.public(project.public));
38 | }
39 |
40 | return res.status(200).json({ tokens: attributes.tokens });
41 | }
42 |
43 | @Middleware([isAuthenticated])
44 | @Post("create")
45 | public async addIdentity(req: Request, res: Response) {
46 | const { id, email } = IdentitySchemas.create.parse(req.body);
47 |
48 | const { userId } = res.locals.auth as IJwt;
49 |
50 | const project = await ProjectService.id(id);
51 |
52 | if (!project) {
53 | throw new NotFound("project");
54 | }
55 |
56 | const existingProject = await prisma.project.findFirst({
57 | where: { email: { endsWith: email.split("@")[1] } },
58 | });
59 |
60 | if (existingProject) {
61 | throw new Error("Domain already attached to another project");
62 | }
63 |
64 | const tokens = await verifyIdentity(email);
65 |
66 | await prisma.project.update({
67 | where: { id },
68 | data: { email, verified: false },
69 | });
70 |
71 | await redis.del(Keys.User.projects(userId));
72 | await redis.del(Keys.Project.id(project.id));
73 |
74 | return res.status(200).json({ success: true, tokens });
75 | }
76 |
77 | @Middleware([isAuthenticated])
78 | @Post("reset")
79 | public async resetIdentity(req: Request, res: Response) {
80 | const { id } = UtilitySchemas.id.parse(req.body);
81 |
82 | const { userId } = res.locals.auth as IJwt;
83 |
84 | const project = await ProjectService.id(id);
85 |
86 | if (!project) {
87 | throw new NotFound("project");
88 | }
89 |
90 | await prisma.project.update({
91 | where: { id },
92 | data: { email: null, verified: false },
93 | });
94 |
95 | await redis.del(Keys.User.projects(userId));
96 | await redis.del(Keys.Project.id(project.id));
97 |
98 | return res.status(200).json({ success: true });
99 | }
100 |
101 | @Post("update")
102 | public async updateIdentities(req: Request, res: Response) {
103 | const count = await prisma.project.count({
104 | where: { email: { not: null } },
105 | });
106 |
107 | for (let i = 0; i < count; i += 99) {
108 | const dbIdentities = await prisma.project.findMany({
109 | where: { email: { not: null } },
110 | select: { id: true, email: true },
111 | skip: i,
112 | take: 99,
113 | });
114 |
115 | const awsIdentities = await getIdentities(dbIdentities.map((i) => i.email as string));
116 |
117 | for (const identity of awsIdentities) {
118 | const projectId = dbIdentities.find((i) => i.email?.endsWith(identity.email));
119 |
120 | const project = await ProjectService.id(projectId?.id as string);
121 |
122 | if (identity.status === "Failed") {
123 | signale.info(`Restarting verification for ${identity.email}`);
124 | try {
125 | void verifyIdentity(identity.email);
126 | } catch (e) {
127 | // @ts-ignore
128 | if (e.Code === "Throttling") {
129 | signale.warn("Throttling detected, waiting 5 seconds");
130 | await new Promise((r) => setTimeout(r, 5000));
131 | }
132 | }
133 | }
134 |
135 | await prisma.project.update({
136 | where: { id: projectId?.id as string },
137 | data: { verified: identity.status === "Success" },
138 | });
139 |
140 | if (project && !project.verified && identity.status === "Success") {
141 | signale.success(`Successfully verified ${identity.email}`);
142 | void ses.setIdentityFeedbackForwardingEnabled({
143 | Identity: identity.email,
144 | ForwardingEnabled: false,
145 | });
146 |
147 | await redis.del(Keys.Project.id(project.id));
148 | await redis.del(Keys.Project.secret(project.secret));
149 | await redis.del(Keys.Project.public(project.public));
150 | }
151 |
152 | if (project?.verified && identity.status !== "Success") {
153 | await redis.del(Keys.Project.id(project.id));
154 | await redis.del(Keys.Project.secret(project.secret));
155 | await redis.del(Keys.Project.public(project.public));
156 | }
157 | }
158 | }
159 |
160 | return res.status(200).json({ success: true });
161 | }
162 | }
163 |
--------------------------------------------------------------------------------
/packages/api/src/controllers/Memberships.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Middleware, Post } from "@overnightjs/core";
2 | import { MembershipSchemas, UtilitySchemas } from "@plunk/shared";
3 | import type { Request, Response } from "express";
4 | import { HttpException, NotAllowed, NotAuthenticated, NotFound } from "../exceptions";
5 | import { type IJwt, isAuthenticated } from "../middleware/auth";
6 | import { MembershipService } from "../services/MembershipService";
7 | import { ProjectService } from "../services/ProjectService";
8 | import { UserService } from "../services/UserService";
9 | import { Keys } from "../services/keys";
10 | import { redis } from "../services/redis";
11 |
12 | @Controller("memberships")
13 | export class Memberships {
14 | @Middleware([isAuthenticated])
15 | @Post("invite")
16 | public async inviteMember(req: Request, res: Response) {
17 | const { id: projectId, email } = MembershipSchemas.invite.parse(req.body);
18 |
19 | const { userId } = res.locals.auth as IJwt;
20 |
21 | const project = await ProjectService.id(projectId);
22 |
23 | if (!project) {
24 | throw new NotFound("project");
25 | }
26 |
27 | const isAdmin = await MembershipService.isAdmin(projectId, userId);
28 |
29 | if (!isAdmin) {
30 | throw new NotAllowed();
31 | }
32 |
33 | const invitedUser = await UserService.email(email);
34 |
35 | if (!invitedUser) {
36 | throw new HttpException(404, "We could not find that user, please ask them to sign up first.");
37 | }
38 |
39 | const alreadyMember = await MembershipService.isMember(project.id, invitedUser.id);
40 |
41 | if (alreadyMember) {
42 | throw new NotAllowed();
43 | }
44 |
45 | await MembershipService.invite(projectId, invitedUser.id, "ADMIN");
46 |
47 | const memberships = await ProjectService.memberships(projectId);
48 |
49 | return res.status(200).json({ success: true, memberships });
50 | }
51 |
52 | @Middleware([isAuthenticated])
53 | @Post("kick")
54 | public async kickMember(req: Request, res: Response) {
55 | const { id: projectId, email } = MembershipSchemas.kick.parse(req.body);
56 |
57 | const { userId } = res.locals.auth as IJwt;
58 |
59 | const user = await UserService.id(userId);
60 |
61 | if (!user) {
62 | throw new NotAuthenticated();
63 | }
64 |
65 | const project = await ProjectService.id(projectId);
66 |
67 | if (!project) {
68 | throw new NotFound("project");
69 | }
70 |
71 | const isAdmin = await MembershipService.isAdmin(projectId, userId);
72 |
73 | if (!isAdmin) {
74 | throw new NotAllowed();
75 | }
76 |
77 | const kickedUser = await UserService.email(email);
78 |
79 | if (!kickedUser) {
80 | throw new NotFound("user");
81 | }
82 |
83 | const isMember = await MembershipService.isMember(project.id, kickedUser.id);
84 |
85 | if (!isMember) {
86 | throw new NotAllowed();
87 | }
88 |
89 | if (userId === kickedUser.id) {
90 | throw new NotAllowed();
91 | }
92 |
93 | await MembershipService.kick(projectId, kickedUser.id);
94 |
95 | const memberships = await ProjectService.memberships(projectId);
96 |
97 | return res.status(200).json({ success: true, memberships });
98 | }
99 |
100 | @Middleware([isAuthenticated])
101 | @Post("leave")
102 | public async leaveProject(req: Request, res: Response) {
103 | const { id: projectId } = UtilitySchemas.id.parse(req.body);
104 |
105 | const { userId } = res.locals.auth as IJwt;
106 |
107 | const user = await UserService.id(userId);
108 |
109 | if (!user) {
110 | throw new NotAuthenticated();
111 | }
112 |
113 | const project = await ProjectService.id(projectId);
114 |
115 | if (!project) {
116 | throw new NotFound("project");
117 | }
118 |
119 | const isMember = await MembershipService.isMember(projectId, userId);
120 |
121 | if (!isMember) {
122 | throw new NotAllowed();
123 | }
124 |
125 | await MembershipService.kick(projectId, userId);
126 |
127 | await redis.del(Keys.User.projects(userId));
128 |
129 | const memberships = await UserService.projects(userId);
130 |
131 | return res.status(200).json({ success: true, memberships });
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/packages/api/src/controllers/Tasks.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Post } from "@overnightjs/core";
2 | import type { Request, Response } from "express";
3 | import signale from "signale";
4 | import { prisma } from "../database/prisma";
5 | import { ContactService } from "../services/ContactService";
6 | import { EmailService } from "../services/EmailService";
7 | import { ProjectService } from "../services/ProjectService";
8 |
9 | @Controller("tasks")
10 | export class Tasks {
11 | @Post()
12 | public async handleTasks(req: Request, res: Response) {
13 | // Get all tasks with a runBy data in the past
14 | const tasks = await prisma.task.findMany({
15 | where: { runBy: { lte: new Date() } },
16 | orderBy: { runBy: "asc" },
17 | include: {
18 | action: { include: { template: true, notevents: true } },
19 | campaign: true,
20 | contact: true,
21 | },
22 | });
23 |
24 | for (const task of tasks) {
25 | const { action, campaign, contact } = task;
26 |
27 | const project = await ProjectService.id(contact.projectId);
28 |
29 | // If the project does not exist or is disabled, delete all tasks
30 | if (!project) {
31 | await prisma.task.deleteMany({
32 | where: {
33 | contact: {
34 | projectId: contact.projectId,
35 | },
36 | },
37 | });
38 | continue;
39 | }
40 |
41 | let subject = "";
42 | let body = "";
43 |
44 | let email = "";
45 | let name = "";
46 |
47 | if (action) {
48 | const { template, notevents } = action;
49 |
50 | if (notevents.length > 0) {
51 | const triggers = await ContactService.triggers(contact.id);
52 | if (notevents.some((e) => triggers.some((t) => t.contactId === contact.id && t.eventId === e.id))) {
53 | await prisma.task.delete({ where: { id: task.id } });
54 | continue;
55 | }
56 | }
57 |
58 | email = project.verified && project.email ? template.email ?? project.email : "no-reply@useplunk.dev";
59 | name = template.from ?? project.from ?? project.name;
60 |
61 | ({ subject, body } = EmailService.format({
62 | subject: template.subject,
63 | body: template.body,
64 | data: {
65 | plunk_id: contact.id,
66 | plunk_email: contact.email,
67 | ...JSON.parse(contact.data ?? "{}"),
68 | },
69 | }));
70 | } else if (campaign) {
71 | email = project.verified && project.email ? campaign.email ?? project.email : "no-reply@useplunk.dev";
72 | name = campaign.from ?? project.from ?? project.name;
73 |
74 | ({ subject, body } = EmailService.format({
75 | subject: campaign.subject,
76 | body: campaign.body,
77 | data: {
78 | plunk_id: contact.id,
79 | plunk_email: contact.email,
80 | ...JSON.parse(contact.data ?? "{}"),
81 | },
82 | }));
83 | }
84 |
85 | const { messageId } = await EmailService.send({
86 | from: {
87 | name,
88 | email,
89 | },
90 | to: [contact.email],
91 | content: {
92 | subject,
93 | html: EmailService.compile({
94 | content: body,
95 | footer: {
96 | unsubscribe: campaign ? true : !!action && action.template.type === "MARKETING",
97 | },
98 | contact: {
99 | id: contact.id,
100 | },
101 | project: {
102 | name: project.name,
103 | },
104 | isHtml: (campaign && campaign.style === "HTML") ?? (!!action && action.template.style === "HTML"),
105 | }),
106 | },
107 | });
108 |
109 | const emailData: {
110 | messageId: string;
111 | contactId: string;
112 | actionId?: string;
113 | campaignId?: string;
114 | } = {
115 | messageId,
116 | contactId: contact.id,
117 | };
118 |
119 | if (action) {
120 | emailData.actionId = action.id;
121 | } else if (campaign) {
122 | emailData.campaignId = campaign.id;
123 | }
124 |
125 | await prisma.email.create({ data: emailData });
126 |
127 | await prisma.task.delete({ where: { id: task.id } });
128 |
129 | signale.success(`Task completed for ${contact.email} from ${project.name}`);
130 | }
131 |
132 | return res.status(200).json({ success: true });
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/packages/api/src/controllers/Users.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get, Middleware } from "@overnightjs/core";
2 | import type { Request, Response } from "express";
3 | import { NotAuthenticated } from "../exceptions";
4 | import { type IJwt, isAuthenticated } from "../middleware/auth";
5 | import { UserService } from "../services/UserService";
6 |
7 | @Controller("users")
8 | export class Users {
9 | @Get("@me")
10 | @Middleware([isAuthenticated])
11 | public async me(req: Request, res: Response) {
12 | const auth = res.locals.auth as IJwt;
13 |
14 | const me = await UserService.id(auth.userId);
15 |
16 | if (!me) {
17 | throw new NotAuthenticated();
18 | }
19 |
20 | return res.status(200).json({ id: me.id, email: me.email });
21 | }
22 |
23 | @Get("@me/projects")
24 | @Middleware([isAuthenticated])
25 | public async meProjects(req: Request, res: Response) {
26 | const auth = res.locals.auth as IJwt;
27 |
28 | const projects = await UserService.projects(auth.userId);
29 |
30 | return res.status(200).json(projects);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/packages/api/src/controllers/Webhooks/Incoming/SNS.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Post } from "@overnightjs/core";
2 | import type { Event } from "@prisma/client";
3 | import type { Request, Response } from "express";
4 | import signale from "signale";
5 | import { prisma } from "../../../database/prisma";
6 | import { ActionService } from "../../../services/ActionService";
7 | import { ProjectService } from "../../../services/ProjectService";
8 |
9 | const eventMap = {
10 | Bounce: "BOUNCED",
11 | Delivery: "DELIVERED",
12 | Open: "OPENED",
13 | Complaint: "COMPLAINT",
14 | Click: "CLICKED",
15 | } as const;
16 |
17 | @Controller("sns")
18 | export class SNSWebhook {
19 | @Post()
20 | public async receiveSNSWebhook(req: Request, res: Response) {
21 | try {
22 | const body = JSON.parse(req.body.Message);
23 |
24 | const email = await prisma.email.findUnique({
25 | where: { messageId: body.mail.messageId },
26 | include: {
27 | contact: true,
28 | action: { include: { template: { include: { events: true } } } },
29 | campaign: { include: { events: true } },
30 | },
31 | });
32 |
33 | if (!email) {
34 | return res.status(200).json({});
35 | }
36 |
37 | const project = await ProjectService.id(email.contact.projectId);
38 |
39 | if (!project) {
40 | return res.status(200).json({ success: false });
41 | }
42 |
43 | // The email was a transactional email
44 | if (email.projectId) {
45 | if (body.eventType === "Click") {
46 | signale.success(`Click received for ${email.contact.email} from ${project.name}`);
47 | await prisma.click.create({
48 | data: { emailId: email.id, link: body.click.link },
49 | });
50 | }
51 |
52 | if (body.eventType === "Complaint") {
53 | signale.warn(`Complaint received for ${email.contact.email} from ${project.name}`);
54 | }
55 |
56 | if (body.eventType === "Bounce") {
57 | signale.warn(`Bounce received for ${email.contact.email} from ${project.name}`);
58 | }
59 |
60 | await prisma.email.update({
61 | where: { messageId: body.mail.messageId },
62 | data: {
63 | status: eventMap[body.eventType as "Bounce" | "Delivery" | "Open" | "Complaint"],
64 | },
65 | });
66 |
67 | return res.status(200).json({ success: true });
68 | }
69 |
70 | if (body.eventType === "Complaint" || body.eventType === "Bounce") {
71 | signale.warn(
72 | `${body.eventType === "Complaint" ? "Complaint" : "Bounce"} received for ${email.contact.email} from ${project.name}`,
73 | );
74 |
75 | await prisma.email.update({
76 | where: { messageId: body.mail.messageId },
77 | data: { status: eventMap[body.eventType as "Bounce" | "Complaint"] },
78 | });
79 |
80 | await prisma.contact.update({
81 | where: { id: email.contactId },
82 | data: { subscribed: false },
83 | });
84 |
85 | return res.status(200).json({ success: true });
86 | }
87 |
88 | if (body.eventType === "Click") {
89 | signale.success(`Click received for ${email.contact.email} from ${project.name}`);
90 |
91 | await prisma.click.create({
92 | data: { emailId: email.id, link: body.click.link },
93 | });
94 |
95 | return res.status(200).json({ success: true });
96 | }
97 |
98 | let event: Event | undefined;
99 |
100 | if (email.action) {
101 | event = email.action.template.events.find((e) =>
102 | e.name.includes(
103 | (body.eventType as "Bounce" | "Delivery" | "Open" | "Complaint" | "Click") === "Delivery"
104 | ? "delivered"
105 | : "opened",
106 | ),
107 | );
108 | }
109 |
110 | if (email.campaign) {
111 | event = email.campaign.events.find((e) =>
112 | e.name.includes(
113 | (body.eventType as "Bounce" | "Delivery" | "Open" | "Complaint" | "Click") === "Delivery"
114 | ? "delivered"
115 | : "opened",
116 | ),
117 | );
118 | }
119 |
120 | if (!event) {
121 | return res.status(200).json({ success: false });
122 | }
123 |
124 | switch (body.eventType as "Delivery" | "Open") {
125 | case "Delivery":
126 | signale.success(`Delivery received for ${email.contact.email} from ${project.name}`);
127 | await prisma.email.update({
128 | where: { messageId: body.mail.messageId },
129 | data: { status: "DELIVERED" },
130 | });
131 |
132 | await prisma.trigger.create({
133 | data: { contactId: email.contactId, eventId: event.id },
134 | });
135 |
136 | break;
137 | case "Open":
138 | signale.success(`Open received for ${email.contact.email} from ${project.name}`);
139 | await prisma.email.update({
140 | where: { messageId: body.mail.messageId },
141 | data: { status: "OPENED" },
142 | });
143 | await prisma.trigger.create({
144 | data: { contactId: email.contactId, eventId: event.id },
145 | });
146 |
147 | break;
148 | }
149 |
150 | if (email.action) {
151 | void ActionService.trigger({ event, contact: email.contact, project });
152 | }
153 | } catch (e) {
154 | if (req.body.SubscribeURL) {
155 | signale.info("--------------");
156 | signale.info("SNS Topic Confirmation URL:");
157 | signale.info(req.body.SubscribeURL);
158 | signale.info("--------------");
159 | } else {
160 | signale.error(e);
161 | }
162 | }
163 |
164 | return res.status(200).json({ success: true });
165 | }
166 | }
167 |
--------------------------------------------------------------------------------
/packages/api/src/controllers/Webhooks/Incoming/index.ts:
--------------------------------------------------------------------------------
1 | import {ChildControllers, Controller} from '@overnightjs/core';
2 | import {SNSWebhook} from './SNS';
3 |
4 | @Controller('incoming')
5 | @ChildControllers([new SNSWebhook()])
6 | export class IncomingWebhooks {}
7 |
--------------------------------------------------------------------------------
/packages/api/src/controllers/Webhooks/index.ts:
--------------------------------------------------------------------------------
1 | import {ChildControllers, Controller} from '@overnightjs/core';
2 | import {IncomingWebhooks} from './Incoming';
3 |
4 | @Controller('webhooks')
5 | @ChildControllers([new IncomingWebhooks()])
6 | export class Webhooks {}
7 |
--------------------------------------------------------------------------------
/packages/api/src/controllers/v1/Events.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Delete, Middleware } from "@overnightjs/core";
2 | import { UtilitySchemas } from "@plunk/shared";
3 | import type { Request, Response } from "express";
4 | import { prisma } from "../../database/prisma";
5 | import { NotFound } from "../../exceptions";
6 | import { type ISecret, isValidSecretKey } from "../../middleware/auth";
7 | import { EventService } from "../../services/EventService";
8 | import { ProjectService } from "../../services/ProjectService";
9 | import { Keys } from "../../services/keys";
10 | import { redis } from "../../services/redis";
11 |
12 | @Controller("events")
13 | export class Events {
14 | @Delete()
15 | @Middleware([isValidSecretKey])
16 | public async deleteEvent(req: Request, res: Response) {
17 | const { sk } = res.locals.auth as ISecret;
18 |
19 | const project = await ProjectService.secret(sk);
20 |
21 | if (!project) {
22 | throw new NotFound("project");
23 | }
24 |
25 | const { id } = UtilitySchemas.id.parse(req.body);
26 |
27 | const event = await EventService.id(id);
28 |
29 | if (!event || event.projectId !== project.id) {
30 | throw new NotFound("event");
31 | }
32 |
33 | await prisma.event.delete({ where: { id } });
34 |
35 | await redis.del(Keys.Event.id(id));
36 | await redis.del(Keys.Event.event(project.id, event.name));
37 |
38 | return res.status(200).json(event);
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/packages/api/src/database/prisma.ts:
--------------------------------------------------------------------------------
1 | import {PrismaClient} from '@prisma/client';
2 | import signale from 'signale';
3 |
4 | let prisma: PrismaClient;
5 | try {
6 | prisma = new PrismaClient();
7 | signale.info('Prisma initialized');
8 | } catch (error) {
9 | signale.error('Failed to initialize Prisma: ', error);
10 | }
11 | export {prisma};
12 |
--------------------------------------------------------------------------------
/packages/api/src/exceptions/index.ts:
--------------------------------------------------------------------------------
1 | export class HttpException extends Error {
2 | public constructor(
3 | public readonly code: number,
4 | message: string,
5 | ) {
6 | super(message);
7 | }
8 | }
9 |
10 | export class NotFound extends HttpException {
11 | /**
12 | * Construct a new NotFound exception
13 | * @param resource The type of resource that was not found
14 | */
15 | public constructor(resource: string) {
16 | super(404, `That ${resource.toLowerCase()} was not found`);
17 | }
18 | }
19 |
20 | export class NotAllowed extends HttpException {
21 | /**
22 | * Construct a new NotAllowed exception
23 | * @param msg
24 | */
25 | public constructor(msg = 'You are not allowed to perform this action') {
26 | super(403, msg);
27 | }
28 | }
29 |
30 | export class NotAuthenticated extends HttpException {
31 | public constructor() {
32 | super(401, 'You need to be authenticated to do this');
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/packages/api/src/middleware/auth.ts:
--------------------------------------------------------------------------------
1 | import dayjs from "dayjs";
2 | import type { NextFunction, Request, Response } from "express";
3 | import jsonwebtoken from "jsonwebtoken";
4 | import { JWT_SECRET } from "../app/constants";
5 | import { HttpException, NotAuthenticated } from "../exceptions";
6 |
7 | export interface IJwt {
8 | type: "jwt";
9 | userId: string;
10 | }
11 |
12 | export interface ISecret {
13 | type: "secret";
14 | sk: string;
15 | }
16 |
17 | export interface IKey {
18 | type: "key";
19 | key: string;
20 | }
21 |
22 | /**
23 | * Middleware to check if this unsubscribe is authenticated on the dashboard
24 | * @param req
25 | * @param res
26 | * @param next
27 | */
28 | export const isAuthenticated = (req: Request, res: Response, next: NextFunction) => {
29 | res.locals.auth = { type: "jwt", userId: parseJwt(req) };
30 |
31 | next();
32 | };
33 |
34 | /**
35 | * Middleware to check if this request is signed with an API secret key
36 | * @param req
37 | * @param res
38 | * @param next
39 | */
40 | export const isValidSecretKey = (req: Request, res: Response, next: NextFunction) => {
41 | res.locals.auth = { type: "secret", sk: parseBearer(req, "secret") };
42 |
43 | next();
44 | };
45 |
46 | export const isValidKey = (req: Request, res: Response, next: NextFunction) => {
47 | res.locals.auth = { type: "key", key: parseBearer(req) };
48 |
49 | next();
50 | };
51 |
52 | export const jwt = {
53 | /**
54 | * Extracts a unsubscribe id from a jwt
55 | * @param token The JWT token
56 | */
57 | verify(token: string): string | null {
58 | try {
59 | const verified = jsonwebtoken.verify(token, JWT_SECRET) as {
60 | id: string;
61 | };
62 | return verified.id;
63 | } catch (e) {
64 | return null;
65 | }
66 | },
67 | /**
68 | * Signs a JWT token
69 | * @param id The unsubscribe's ID to sign into a jwt token
70 | */
71 | sign(id: string): string {
72 | return jsonwebtoken.sign({ id }, JWT_SECRET, {
73 | expiresIn: "168h",
74 | });
75 | },
76 | /**
77 | * Find out when a JWT expires
78 | * @param token The unsubscribe's jwt token
79 | */
80 | expires(token: string): dayjs.Dayjs {
81 | const { exp } = jsonwebtoken.verify(token, JWT_SECRET) as {
82 | exp?: number;
83 | };
84 | return dayjs(exp);
85 | },
86 | };
87 |
88 | /**
89 | * Parse a unsubscribe's ID from the request JWT token
90 | * @param request The express request object
91 | */
92 | export function parseJwt(request: Request): string {
93 | const token: string | undefined = request.cookies.token;
94 |
95 | if (!token) {
96 | throw new NotAuthenticated();
97 | }
98 |
99 | const id = jwt.verify(token);
100 |
101 | if (!id) {
102 | throw new NotAuthenticated();
103 | }
104 |
105 | return id;
106 | }
107 |
108 | /**
109 | * Parse a bearer token from the request headers
110 | * @param request The express request object
111 | * @param type
112 | */
113 | export function parseBearer(request: Request, type?: "secret" | "public"): string {
114 | const bearer: string | undefined = request.headers.authorization;
115 |
116 | if (!bearer) {
117 | throw new HttpException(401, "No authorization header passed");
118 | }
119 |
120 | if (!bearer.includes("Bearer")) {
121 | throw new HttpException(401, "Please add Bearer in front of your API key");
122 | }
123 |
124 | const split = bearer.split(" ");
125 |
126 | if (!(split[0] === "Bearer") || split.length > 2) {
127 | throw new HttpException(401, "Your authorization header is malformed. Please pass your API key as Bearer sk_...");
128 | }
129 |
130 | if (!type && !split[1].startsWith("sk_") && !split[1].startsWith("pk_")) {
131 | throw new HttpException(401, "Your API key could not be parsed. API keys start with sk_ or pk_");
132 | }
133 |
134 | if (!type) {
135 | return split[1];
136 | }
137 |
138 | if (type === "secret" && split[1].startsWith("pk_")) {
139 | throw new HttpException(401, "You attached a public key but this route may only be accessed with a secret key");
140 | }
141 |
142 | if (type === "secret" && !split[1].startsWith("sk_")) {
143 | throw new HttpException(
144 | 401,
145 | "Your secret key could not be parsed. Secret keys start with sk_ and should be passed in the authorization header as Bearer sk_...",
146 | );
147 | }
148 |
149 | if (type === "public" && !split[1].startsWith("pk_")) {
150 | throw new HttpException(
151 | 401,
152 | "Your public key could not be parsed. Public keys start with pk_ and should be passed in the authorization header as Bearer sk_...",
153 | );
154 | }
155 |
156 | return split[1];
157 | }
158 |
--------------------------------------------------------------------------------
/packages/api/src/services/ActionService.ts:
--------------------------------------------------------------------------------
1 | import type { Contact, Event, Project } from "@prisma/client";
2 | import dayjs from "dayjs";
3 | import { prisma } from "../database/prisma";
4 | import { ContactService } from "./ContactService";
5 | import { EmailService } from "./EmailService";
6 | import { Keys } from "./keys";
7 | import { wrapRedis } from "./redis";
8 |
9 | export class ActionService {
10 | /**
11 | * Gets an action by its ID
12 | * @param id
13 | */
14 | public static id(id: string) {
15 | return wrapRedis(Keys.Action.id(id), async () => {
16 | return prisma.action.findUnique({
17 | where: { id },
18 | include: {
19 | events: true,
20 | notevents: true,
21 | triggers: true,
22 | emails: true,
23 | template: true,
24 | },
25 | });
26 | });
27 | }
28 |
29 | /**
30 | * Gets all actions that share an event with the action with the given ID
31 | * @param id
32 | */
33 | public static related(id: string) {
34 | return wrapRedis(Keys.Action.related(id), async () => {
35 | const action = await ActionService.id(id);
36 |
37 | if (!action) {
38 | return [];
39 | }
40 |
41 | return prisma.action.findMany({
42 | where: {
43 | events: { some: { id: { in: action.events.map((e) => e.id) } } },
44 | id: { not: action.id },
45 | },
46 | include: { events: true },
47 | });
48 | });
49 | }
50 |
51 | /**
52 | * Gets all actions that have an event as a trigger
53 | * @param eventId
54 | */
55 | public static event(eventId: string) {
56 | return wrapRedis(Keys.Action.event(eventId), async () => {
57 | return prisma.event.findUniqueOrThrow({ where: { id: eventId } }).actions({
58 | include: { events: true, template: true, notevents: true },
59 | });
60 | });
61 | }
62 |
63 | /**
64 | * Takes a contact and an event and triggers all required actions
65 | * @param contact
66 | * @param event
67 | * @param project
68 | */
69 | public static async trigger({ event, contact, project }: { event: Event; contact: Contact; project: Project }) {
70 | const actions = await ActionService.event(event.id);
71 |
72 | const triggers = await ContactService.triggers(contact.id);
73 |
74 | for (const action of actions) {
75 | const hasTriggeredAction = !!triggers.find((t) => t.actionId === action.id);
76 |
77 | if (action.runOnce && hasTriggeredAction) {
78 | // User has already triggered this run once action
79 | continue;
80 | }
81 |
82 | if (action.notevents.length > 0 && action.notevents.some((e) => triggers.some((t) => t.eventId === e.id))) {
83 | continue;
84 | }
85 |
86 | let triggeredEvents = triggers.filter((t) => t.eventId === event.id);
87 |
88 | if (hasTriggeredAction) {
89 | const lastActionTrigger = triggers.filter((t) => t.contactId === contact.id && t.actionId === action.id)[0];
90 |
91 | triggeredEvents = triggeredEvents.filter((e) => e.createdAt > lastActionTrigger.createdAt);
92 | }
93 |
94 | const updatedTriggers = [...new Set(triggeredEvents.map((t) => t.eventId))];
95 | const requiredTriggers = action.events.map((e) => e.id);
96 |
97 | if (updatedTriggers.sort().join(",") !== requiredTriggers.sort().join(",")) {
98 | // Not all required events have been triggered
99 | continue;
100 | }
101 |
102 | await prisma.trigger.create({
103 | data: { actionId: action.id, contactId: contact.id },
104 | });
105 |
106 | if (!contact.subscribed && action.template.type === "MARKETING") {
107 | continue;
108 | }
109 |
110 | if (action.delay === 0) {
111 | const { subject, body } = EmailService.format({
112 | subject: action.template.subject,
113 | body: action.template.body,
114 | data: {
115 | plunk_id: contact.id,
116 | plunk_email: contact.email,
117 | ...JSON.parse(contact.data ?? "{}"),
118 | },
119 | });
120 |
121 | const { messageId } = await EmailService.send({
122 | from: {
123 | name: action.template.from ?? project.from ?? project.name,
124 | email: project.verified && project.email ? action.template.email ?? project.email : "no-reply@useplunk.dev",
125 | },
126 | to: [contact.email],
127 | content: {
128 | subject,
129 | html: EmailService.compile({
130 | content: body,
131 | footer: {
132 | unsubscribe: action.template.type === "MARKETING",
133 | },
134 | contact: {
135 | id: contact.id,
136 | },
137 | project: {
138 | name: project.name,
139 | },
140 | isHtml: action.template.style === "HTML",
141 | }),
142 | },
143 | });
144 |
145 | await prisma.email.create({
146 | data: {
147 | messageId,
148 | actionId: action.id,
149 | contactId: contact.id,
150 | },
151 | });
152 | } else {
153 | await prisma.task.create({
154 | data: {
155 | actionId: action.id,
156 | contactId: contact.id,
157 | runBy: dayjs().add(action.delay, "minutes").toDate(),
158 | },
159 | });
160 | }
161 | }
162 | }
163 | }
164 |
--------------------------------------------------------------------------------
/packages/api/src/services/AuthService.ts:
--------------------------------------------------------------------------------
1 | import {prisma} from '../database/prisma';
2 | import {verifyHash} from '../util/hash';
3 |
4 | export class AuthService {
5 | public static async verifyCredentials(email: string, password: string) {
6 | const user = await prisma.user.findUnique({
7 | where: {
8 | email: email,
9 | },
10 | });
11 |
12 | if (!user?.password) {
13 | return false;
14 | }
15 |
16 | return await verifyHash(password, user.password);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/packages/api/src/services/CampaignService.ts:
--------------------------------------------------------------------------------
1 | import {Keys} from './keys';
2 | import {wrapRedis} from './redis';
3 | import {prisma} from '../database/prisma';
4 |
5 | export class CampaignService {
6 | public static id(id: string) {
7 | return wrapRedis(Keys.Campaign.id(id), async () => {
8 | return prisma.campaign.findUnique({
9 | where: {id},
10 | include: {
11 | recipients: {select: {id: true}},
12 | emails: {select: {id: true, status: true, contact: {select: {id: true, email: true}}}},
13 | },
14 | });
15 | });
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/packages/api/src/services/ContactService.ts:
--------------------------------------------------------------------------------
1 | import {Keys} from './keys';
2 | import {wrapRedis} from './redis';
3 | import {prisma} from '../database/prisma';
4 |
5 | export class ContactService {
6 | public static id(id: string) {
7 | return wrapRedis(Keys.Contact.id(id), async () => {
8 | return prisma.contact.findUnique({
9 | where: {id},
10 | include: {triggers: {include: {event: true, action: true}}, emails: {where: {subject: {not: null}}}},
11 | });
12 | });
13 | }
14 |
15 | public static email(projectId: string, email: string) {
16 | return wrapRedis(Keys.Contact.email(projectId, email), () => {
17 | return prisma.contact.findFirst({where: {projectId, email}});
18 | });
19 | }
20 |
21 | public static async triggers(id: string) {
22 | return prisma.contact.findUniqueOrThrow({where: {id}}).triggers();
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/packages/api/src/services/EventService.ts:
--------------------------------------------------------------------------------
1 | import {prisma} from '../database/prisma';
2 | import {REDIS_ONE_MINUTE, wrapRedis} from './redis';
3 | import {Keys} from './keys';
4 |
5 | export class EventService {
6 | public static id(id: string) {
7 | return wrapRedis(
8 | Keys.Event.id(id),
9 | () => {
10 | return prisma.event.findUnique({where: {id}});
11 | },
12 | REDIS_ONE_MINUTE * 1440,
13 | );
14 | }
15 |
16 | public static event(projectId: string, name: string) {
17 | return wrapRedis(
18 | Keys.Event.event(projectId, name),
19 | () => {
20 | return prisma.event.findFirst({where: {projectId, name}});
21 | },
22 | REDIS_ONE_MINUTE * 1440,
23 | );
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/packages/api/src/services/MembershipService.ts:
--------------------------------------------------------------------------------
1 | import type { Role } from "@prisma/client";
2 | import { NODE_ENV } from "../app/constants";
3 | import { prisma } from "../database/prisma";
4 | import { Keys } from "./keys";
5 | import { redis, wrapRedis } from "./redis";
6 |
7 | export class MembershipService {
8 | public static async isMember(projectId: string, userId: string) {
9 | return wrapRedis(Keys.ProjectMembership.isMember(projectId, userId), async () => {
10 | if (NODE_ENV === "development") {
11 | return true;
12 | }
13 |
14 | const membership = await prisma.projectMembership.findFirst({
15 | where: { projectId, userId },
16 | });
17 |
18 | return !!membership;
19 | });
20 | }
21 |
22 | public static async isAdmin(projectId: string, userId: string) {
23 | return wrapRedis(Keys.ProjectMembership.isAdmin(projectId, userId), async () => {
24 | if (NODE_ENV === "development") {
25 | return true;
26 | }
27 |
28 | const membership = await prisma.projectMembership.findFirst({
29 | where: { projectId, userId, role: { in: ["ADMIN", "OWNER"] } },
30 | });
31 |
32 | return !!membership;
33 | });
34 | }
35 |
36 | public static async isOwner(projectId: string, userId: string) {
37 | return wrapRedis(Keys.ProjectMembership.isOwner(projectId, userId), async () => {
38 | if (NODE_ENV === "development") {
39 | return true;
40 | }
41 |
42 | const membership = await prisma.projectMembership.findFirst({
43 | where: { projectId, userId, role: "OWNER" },
44 | });
45 |
46 | return !!membership;
47 | });
48 | }
49 |
50 | public static async kick(projectId: string, userId: string) {
51 | await prisma.projectMembership.delete({
52 | where: { userId_projectId: { projectId, userId } },
53 | });
54 |
55 | await redis.del(Keys.Project.memberships(projectId));
56 | await redis.del(Keys.User.projects(userId));
57 | }
58 |
59 | public static async invite(projectId: string, userId: string, role: Role) {
60 | await prisma.projectMembership.create({
61 | data: { projectId, userId, role },
62 | });
63 |
64 | await redis.del(Keys.Project.memberships(projectId));
65 | await redis.del(Keys.User.projects(userId));
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/packages/api/src/services/TemplateService.ts:
--------------------------------------------------------------------------------
1 | import {wrapRedis} from './redis';
2 | import {prisma} from '../database/prisma';
3 | import {Keys} from './keys';
4 |
5 | export class TemplateService {
6 | public static id(id: string) {
7 | return wrapRedis(Keys.Template.id(id), async () => {
8 | return prisma.template.findUnique({where: {id}, include: {actions: true}});
9 | });
10 | }
11 |
12 | public static actions(templateId: string) {
13 | return wrapRedis(Keys.Template.actions(templateId), async () => {
14 | return prisma.template.findUnique({where: {id: templateId}}).actions();
15 | });
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/packages/api/src/services/UserService.ts:
--------------------------------------------------------------------------------
1 | import dayjs from "dayjs";
2 | import { NODE_ENV } from "../app/constants";
3 | import { prisma } from "../database/prisma";
4 | import { Keys } from "./keys";
5 | import { wrapRedis } from "./redis";
6 |
7 | export class UserService {
8 | public static readonly COOKIE_NAME = "token";
9 |
10 | public static id(id: string) {
11 | return wrapRedis(Keys.User.id(id), () => {
12 | return prisma.user.findUnique({
13 | where: { id },
14 | });
15 | });
16 | }
17 |
18 | public static email(email: string) {
19 | return wrapRedis(Keys.User.email(email), () => {
20 | return prisma.user.findUnique({
21 | where: { email },
22 | });
23 | });
24 | }
25 |
26 | public static async projects(id: string) {
27 | return wrapRedis(Keys.User.projects(id), async () => {
28 | const user = await prisma.user.findUnique({
29 | where: { id },
30 | include: { memberships: true },
31 | });
32 |
33 | if (!user) {
34 | return [];
35 | }
36 |
37 | return prisma.project.findMany({
38 | where: {
39 | id: {
40 | in: user.memberships.map((project) => project.projectId),
41 | },
42 | },
43 | orderBy: { name: "asc" },
44 | });
45 | });
46 | }
47 |
48 | /**
49 | * Generates cookie options
50 | * @param expires An optional expiry for this cookie (useful for a logout)
51 | */
52 | public static cookieOptions(expires?: Date) {
53 | return {
54 | httpOnly: true,
55 | expires: expires ?? dayjs().add(168, "hours").toDate(),
56 | secure: NODE_ENV !== "development",
57 | sameSite: "lax",
58 | path: "/",
59 | } as const;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/packages/api/src/services/keys.ts:
--------------------------------------------------------------------------------
1 | export const Keys = {
2 | User: {
3 | id(id: string): string {
4 | return `account:id:${id}`;
5 | },
6 | email(email: string): string {
7 | return `account:${email}`;
8 | },
9 | projects(id: string): string {
10 | return `account:${id}:projects`;
11 | },
12 | },
13 | Project: {
14 | id(id: string): string {
15 | return `project:id:${id}`;
16 | },
17 | secret(secretKey: string): string {
18 | return `project:secret:${secretKey}`;
19 | },
20 | public(publicKey: string): string {
21 | return `project:public:${publicKey}`;
22 | },
23 | memberships(id: string): string {
24 | return `project:${id}:memberships`;
25 | },
26 | usage(id: string): string {
27 | return `project:${id}:usage`;
28 | },
29 | events(id: string, triggers: boolean): string {
30 | if (triggers) {
31 | return `project:${id}:events:triggers`;
32 | }
33 |
34 | return `project:${id}:events`;
35 | },
36 | metadata(id: string): string {
37 | return `project:${id}:metadata`;
38 | },
39 | actions(id: string): string {
40 | return `project:${id}:actions`;
41 | },
42 | templates(id: string): string {
43 | return `project:${id}:templates`;
44 | },
45 | feed(id: string): string {
46 | return `project:${id}:feed`;
47 | },
48 | contacts(
49 | id: string,
50 | options?: {
51 | page?: number;
52 | count?: boolean;
53 | },
54 | ): string {
55 | if (options?.count) {
56 | return `project:${id}:contacts:count`;
57 | }
58 |
59 | if (options?.page) {
60 | return `project:${id}:contacts:page:${options.page}`;
61 | }
62 |
63 | return `project:${id}:contacts`;
64 | },
65 | campaigns(id: string): string {
66 | return `project:${id}:campaigns`;
67 | },
68 | analytics(id: string): string {
69 | return `project:${id}:analytics`;
70 | },
71 | emails(
72 | id: string,
73 | options?: {
74 | count?: boolean;
75 | },
76 | ): string {
77 | if (options?.count) {
78 | return `project:${id}:emails:count`;
79 | }
80 |
81 | return `project:${id}:emails`;
82 | },
83 | },
84 | ProjectMembership: {
85 | isMember(projectId: string, accountId: string) {
86 | return `project:id:${projectId}:ismember:${accountId}`;
87 | },
88 | isAdmin(projectId: string, accountId: string) {
89 | return `project:id:${projectId}:isadmin:${accountId}`;
90 | },
91 | isOwner(projectId: string, accountId: string) {
92 | return `project:id:${projectId}:isowner:${accountId}`;
93 | },
94 | },
95 | Campaign: {
96 | id(id: string): string {
97 | return `campaign:id:${id}`;
98 | },
99 | },
100 | Template: {
101 | id(id: string): string {
102 | return `template:id:${id}`;
103 | },
104 | actions(templateId: string): string {
105 | return `template:id:${templateId}:actions`;
106 | },
107 | },
108 | Webhook: {
109 | id(id: string): string {
110 | return `webhook:id:${id}`;
111 | },
112 | },
113 | Contact: {
114 | id(id: string): string {
115 | return `contact:id:${id}`;
116 | },
117 | email(projectId: string, email: string): string {
118 | return `project:id:${projectId}:contact:email:${email}`;
119 | },
120 | },
121 | Action: {
122 | id(id: string): string {
123 | return `action:id:${id}`;
124 | },
125 | related(id: string): string {
126 | return `action:id:${id}:related`;
127 | },
128 | event(eventId: string): string {
129 | return `action:event:id:${eventId}`;
130 | },
131 | },
132 | Event: {
133 | id(id: string): string {
134 | return `event:id:${id}`;
135 | },
136 | event(projectId: string, name: string): string {
137 | return `project:id:${projectId}:event:name:${name}`;
138 | },
139 | },
140 | };
141 |
--------------------------------------------------------------------------------
/packages/api/src/services/redis.ts:
--------------------------------------------------------------------------------
1 | import Redis from 'ioredis';
2 | import {REDIS_URL} from '../app/constants';
3 | import signale from "signale";
4 |
5 | let redis: Redis;
6 | try {
7 | redis = new Redis(REDIS_URL);
8 | const infoString = redis.info();
9 | signale.info('Redis initialized: ', infoString);
10 | } catch (error) {
11 | signale.error('Failed to initialize Redis: ', error);
12 | }
13 | export {redis};
14 |
15 | export const REDIS_ONE_MINUTE = 60;
16 | export const REDIS_DEFAULT_EXPIRY = REDIS_ONE_MINUTE / 60;
17 |
18 | /**
19 | * @param key The key for redis (use Keys#)
20 | * @param fn The function to return a resource. Can be a promise
21 | * @param seconds The amount of seconds to hold this resource in redis for. Defaults to 60
22 | */
23 | export async function wrapRedis(key: string, fn: () => Promise, seconds = REDIS_DEFAULT_EXPIRY): Promise {
24 | const cached = await redis.get(key);
25 | if (cached) {
26 | return JSON.parse(cached);
27 | }
28 |
29 | const recent = await fn();
30 |
31 | if (recent) {
32 | await redis.set(key, JSON.stringify(recent), 'EX', seconds);
33 | }
34 |
35 | return recent;
36 | }
37 |
--------------------------------------------------------------------------------
/packages/api/src/util/hash.ts:
--------------------------------------------------------------------------------
1 | import bcrypt from "bcrypt";
2 |
3 | /**
4 | * Verifies a hash against a password
5 | * @param {string} pass The password
6 | * @param {string} hash The hash
7 | */
8 | export const verifyHash = (pass: string, hash: string) => {
9 | return new Promise((resolve, reject) => {
10 | void bcrypt.compare(pass, hash, (err, res) => {
11 | if (err) {
12 | return reject(err);
13 | }
14 | return resolve(res);
15 | });
16 | });
17 | };
18 |
19 | /**
20 | * Generates a hash from plain text
21 | * @param {string} pass The password
22 | * @returns {Promise} Password hash
23 | */
24 | export const createHash = (pass: string): Promise => {
25 | return new Promise((resolve, reject) => {
26 | void bcrypt.hash(pass, 10, (err, res) => {
27 | if (err) {
28 | return reject(err);
29 | }
30 | resolve(res);
31 | });
32 | });
33 | };
34 |
--------------------------------------------------------------------------------
/packages/api/src/util/ses.ts:
--------------------------------------------------------------------------------
1 | import { SES } from "@aws-sdk/client-ses";
2 | import { AWS_ACCESS_KEY_ID, AWS_REGION, AWS_SECRET_ACCESS_KEY } from "../app/constants";
3 |
4 | export const ses = new SES({
5 | apiVersion: "2010-12-01",
6 | region: AWS_REGION,
7 | credentials: {
8 | accessKeyId: AWS_ACCESS_KEY_ID,
9 | secretAccessKey: AWS_SECRET_ACCESS_KEY,
10 | },
11 | });
12 |
13 | export const getIdentities = async (identities: string[]) => {
14 | const res = await ses.getIdentityVerificationAttributes({
15 | Identities: identities.flatMap((identity) => [identity.split("@")[1]]),
16 | });
17 |
18 | const parsedResult = Object.entries(res.VerificationAttributes ?? {});
19 | return parsedResult.map((obj) => {
20 | return { email: obj[0], status: obj[1].VerificationStatus };
21 | });
22 | };
23 |
24 | export const verifyIdentity = async (email: string) => {
25 | const DKIM = await ses.verifyDomainDkim({
26 | Domain: email.includes("@") ? email.split("@")[1] : email,
27 | });
28 |
29 | await ses.setIdentityMailFromDomain({
30 | Identity: email.includes("@") ? email.split("@")[1] : email,
31 | MailFromDomain: `plunk.${email.includes("@") ? email.split("@")[1] : email}`,
32 | });
33 |
34 | return DKIM.DkimTokens;
35 | };
36 |
37 | export const getIdentityVerificationAttributes = async (email: string) => {
38 | const attributes = await ses.getIdentityDkimAttributes({
39 | Identities: [email, email.split("@")[1]],
40 | });
41 |
42 | const parsedAttributes = Object.entries(attributes.DkimAttributes ?? {});
43 |
44 | return {
45 | email: parsedAttributes[0][0],
46 | tokens: parsedAttributes[0][1].DkimTokens,
47 | status: parsedAttributes[0][1].DkimVerificationStatus,
48 | };
49 | };
50 |
--------------------------------------------------------------------------------
/packages/api/src/util/tokens.ts:
--------------------------------------------------------------------------------
1 | import { randomBytes } from "node:crypto";
2 |
3 | /**
4 | * A function that generates a random 24 byte API secret
5 | * @param type
6 | */
7 | export function generateToken(type: "secret" | "public") {
8 | return `${type === "secret" ? "sk" : "pk"}_${randomBytes(24).toString("hex")}`;
9 | }
10 |
--------------------------------------------------------------------------------
/packages/api/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "rootDir": "src",
5 | "outDir": "dist"
6 | },
7 | "exclude": [
8 | "node_modules",
9 | "dist"
10 | ]
11 | }
--------------------------------------------------------------------------------
/packages/dashboard/.env.example:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_API_URI=http://localhost:4000
2 | NEXT_PUBLIC_AWS_REGION=eu-west-3
--------------------------------------------------------------------------------
/packages/dashboard/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/packages/dashboard/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | module.exports = {
3 | reactStrictMode: true,
4 | swcMinify: true,
5 | webpack(config) {
6 | config.module.rules.push({
7 | test: /\.svg$/,
8 | use: ["@svgr/webpack"],
9 | });
10 |
11 | config.module.rules.push({
12 | test: [/src\/(components|layouts)\/index.ts/i],
13 | sideEffects: false,
14 | });
15 |
16 | return config;
17 | },
18 | };
19 |
--------------------------------------------------------------------------------
/packages/dashboard/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@plunk/dashboard",
3 | "version": "1.0.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev -p 3000",
7 | "build": "next build",
8 | "start": "next start",
9 | "clean": "rimraf .next node_modules .turbo"
10 | },
11 | "dependencies": {
12 | "@hookform/resolvers": "^3.9.0",
13 | "@monaco-editor/react": "^4.6.0",
14 | "@plunk/shared": "1.0.0",
15 | "@svgr/webpack": "^8.1.0",
16 | "@tippyjs/react": "^4.2.6",
17 | "@tiptap/core": "^2.5.4",
18 | "@tiptap/extension-color": "^2.5.4",
19 | "@tiptap/extension-dropcursor": "^2.5.4",
20 | "@tiptap/extension-font-family": "^2.5.4",
21 | "@tiptap/extension-image": "^2.5.4",
22 | "@tiptap/extension-link": "^2.5.4",
23 | "@tiptap/extension-placeholder": "^2.5.4",
24 | "@tiptap/extension-text-align": "^2.5.4",
25 | "@tiptap/extension-text-style": "^2.5.4",
26 | "@tiptap/extension-typography": "^2.5.4",
27 | "@tiptap/pm": "^2.5.4",
28 | "@tiptap/react": "^2.5.4",
29 | "@tiptap/starter-kit": "^2.5.4",
30 | "@tiptap/suggestion": "^2.5.4",
31 | "@uiball/loaders": "^1.3.1",
32 | "classnames": "^2.5.1",
33 | "dayjs": "^1.11.12",
34 | "framer-motion": "^11.3.7",
35 | "jotai": "2.9.0",
36 | "lucide-react": "^0.408.0",
37 | "next": "14.2.5",
38 | "next-seo": "^6.5.0",
39 | "nprogress": "^0.2.0",
40 | "prosemirror-commands": "^1.5.2",
41 | "prosemirror-dropcursor": "^1.8.1",
42 | "prosemirror-gapcursor": "^1.3.2",
43 | "prosemirror-history": "^1.4.1",
44 | "prosemirror-keymap": "^1.2.2",
45 | "prosemirror-schema-list": "^1.4.1",
46 | "react": "18.3.1",
47 | "react-dom": "18.3.1",
48 | "react-hook-form": "^7.52.1",
49 | "react-syntax-highlighter": "^15.5.0",
50 | "react-window": "^1.8.10",
51 | "recharts": "^2.12.7",
52 | "sharp": "^0.33.4",
53 | "sonner": "^1.5.0",
54 | "swr": "2.2.5",
55 | "tailwind-scrollbar": "^3.1.0",
56 | "zod": "^3.23.8"
57 | },
58 | "devDependencies": {
59 | "@tailwindcss/aspect-ratio": "^0.4.2",
60 | "@tailwindcss/forms": "^0.5.7",
61 | "@tailwindcss/typography": "^0.5.13",
62 | "@types/node": "20.14.11",
63 | "@types/nprogress": "^0.2.3",
64 | "@types/react": "18.3.3",
65 | "@types/react-syntax-highlighter": "^15.5.13",
66 | "@types/react-window": "^1",
67 | "autoprefixer": "^10.4.19",
68 | "postcss": "^8.4.39",
69 | "tailwindcss": "^3.4.6",
70 | "typescript": "5.5.3"
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/packages/dashboard/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/packages/dashboard/public/assets/card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/useplunk/plunk/d97353cee68e57e408c5f4233d1ce939c711ded9/packages/dashboard/public/assets/card.png
--------------------------------------------------------------------------------
/packages/dashboard/public/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/useplunk/plunk/d97353cee68e57e408c5f4233d1ce939c711ded9/packages/dashboard/public/assets/logo.png
--------------------------------------------------------------------------------
/packages/dashboard/public/favicon/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/useplunk/plunk/d97353cee68e57e408c5f4233d1ce939c711ded9/packages/dashboard/public/favicon/android-chrome-192x192.png
--------------------------------------------------------------------------------
/packages/dashboard/public/favicon/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/useplunk/plunk/d97353cee68e57e408c5f4233d1ce939c711ded9/packages/dashboard/public/favicon/android-chrome-512x512.png
--------------------------------------------------------------------------------
/packages/dashboard/public/favicon/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/useplunk/plunk/d97353cee68e57e408c5f4233d1ce939c711ded9/packages/dashboard/public/favicon/apple-touch-icon.png
--------------------------------------------------------------------------------
/packages/dashboard/public/favicon/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #ffffff
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/packages/dashboard/public/favicon/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/useplunk/plunk/d97353cee68e57e408c5f4233d1ce939c711ded9/packages/dashboard/public/favicon/favicon-16x16.png
--------------------------------------------------------------------------------
/packages/dashboard/public/favicon/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/useplunk/plunk/d97353cee68e57e408c5f4233d1ce939c711ded9/packages/dashboard/public/favicon/favicon-32x32.png
--------------------------------------------------------------------------------
/packages/dashboard/public/favicon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/useplunk/plunk/d97353cee68e57e408c5f4233d1ce939c711ded9/packages/dashboard/public/favicon/favicon.ico
--------------------------------------------------------------------------------
/packages/dashboard/public/favicon/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/useplunk/plunk/d97353cee68e57e408c5f4233d1ce939c711ded9/packages/dashboard/public/favicon/mstile-150x150.png
--------------------------------------------------------------------------------
/packages/dashboard/public/favicon/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
74 |
--------------------------------------------------------------------------------
/packages/dashboard/public/favicon/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "short_name": "",
4 | "icons": [
5 | {
6 | "src": "/favicon/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/favicon/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Alert/Alert.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export interface AlertProps {
4 | type: 'info' | 'danger' | 'warning' | 'success';
5 | title: string;
6 | children?: string | React.ReactNode;
7 | }
8 |
9 | const styles = {
10 | info: 'bg-blue-50 text-blue-800 border-blue-300',
11 | danger: 'bg-red-50 text-red-800 border-red-300',
12 | warning: 'bg-yellow-50 text-yellow-800 border-yellow-300',
13 | success: 'bg-green-50 text-green-800 border-green-300',
14 | };
15 |
16 | /**
17 | * @param root0
18 | * @param root0.type
19 | * @param root0.title
20 | * @param root0.children
21 | */
22 | export default function Alert({type = 'info', title, children}: AlertProps) {
23 | const classNames = ['w-full px-7 py-5 border rounded-lg'];
24 | classNames.push(styles[type]);
25 |
26 | return (
27 |
28 |
{title}
29 |
{children}
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Alert/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as Alert} from './Alert';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Badge/Badge.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export interface BadgeProps {
4 | type: 'info' | 'danger' | 'warning' | 'success' | 'purple';
5 | children: string;
6 | }
7 |
8 | const styles = {
9 | info: 'bg-blue-100 text-blue-800',
10 | danger: 'bg-red-100 text-red-800',
11 | warning: 'bg-yellow-100 text-yellow-800',
12 | success: 'bg-green-100 text-green-800',
13 | purple: 'bg-purple-100 text-purple-800',
14 | };
15 |
16 | /**
17 | * @param root0
18 | * @param root0.type
19 | * @param root0.children
20 | */
21 | export default function Badge({type = 'info', children}: BadgeProps) {
22 | const classNames = ['inline-flex items-center px-2 py-0.5 rounded text-xs font-medium'];
23 | classNames.push(styles[type]);
24 |
25 | return {children};
26 | }
27 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Badge/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as Badge} from './Badge';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Card/Card.tsx:
--------------------------------------------------------------------------------
1 | import React, {MutableRefObject, useEffect, useState} from 'react';
2 | import {AnimatePresence, motion} from 'framer-motion';
3 |
4 | export interface CardProps extends React.HTMLAttributes {
5 | title?: string;
6 | description?: string;
7 | actions?: React.ReactNode;
8 | options?: React.ReactNode;
9 | }
10 |
11 | /**
12 | * @param root0
13 | * @param root0.title
14 | * @param root0.description
15 | * @param root0.children
16 | * @param root0.className
17 | * @param root0.actions
18 | * @param root0.options
19 | */
20 | export default function Card({title, description, children, className, actions, options}: CardProps) {
21 | const ref = React.createRef();
22 |
23 | const [optionsOpen, setOptionsOpen] = useState(false);
24 |
25 | useEffect(() => {
26 | const mutableRef = ref as MutableRefObject;
27 |
28 | const handleClickOutside = (event: any) => {
29 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
30 | if (mutableRef.current && !mutableRef.current.contains(event.target) && optionsOpen) {
31 | setOptionsOpen(false);
32 | }
33 | };
34 |
35 | document.addEventListener('mousedown', handleClickOutside);
36 | return () => {
37 | document.removeEventListener('mousedown', handleClickOutside);
38 | };
39 | }, [ref]);
40 |
41 | return (
42 |
43 |
44 |
45 |
46 |
{title}
47 |
{description}
48 |
49 |
{actions}
50 |
51 |
52 | {options && (
53 |
54 |
55 |
75 |
76 |
77 |
78 | {optionsOpen && (
79 |
90 | {options}
91 |
92 | )}
93 |
94 |
95 | )}
96 |
97 |
98 |
{children}
99 |
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Card/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as Card} from './Card';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/CodeBlock/CodeBlock.tsx:
--------------------------------------------------------------------------------
1 | import SyntaxHighlighter from 'react-syntax-highlighter';
2 | import React from 'react';
3 |
4 | export interface CodeBlockProps {
5 | language: string;
6 | code: string;
7 | style?: React.CSSProperties;
8 | }
9 |
10 | /**
11 | *
12 | * @param root0
13 | * @param root0.code
14 | * @param root0.language
15 | * @param root0.style
16 | */
17 | export default function ({code, language, style}: CodeBlockProps) {
18 | return (
19 |
115 | {code}
116 |
117 | );
118 | }
119 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/CodeBlock/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as CodeBlock} from './CodeBlock';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Input/Dropdown/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as Dropdown} from './Dropdown';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Input/Input/Input.tsx:
--------------------------------------------------------------------------------
1 | import {FieldError, UseFormRegisterReturn} from 'react-hook-form';
2 | import {AnimatePresence, motion} from 'framer-motion';
3 | import React from 'react';
4 |
5 | export interface InputProps {
6 | label?: string;
7 | placeholder?: string;
8 | type?: 'text' | 'email' | 'password' | 'number';
9 | register: UseFormRegisterReturn;
10 | error?: FieldError;
11 | className?: string;
12 | min?: number;
13 | max?: number;
14 | }
15 |
16 | /**
17 | *
18 | * @param props
19 | * @param props.label
20 | * @param props.type
21 | * @param props.register
22 | * @param props.error
23 | * @param props.placeholder
24 | * @param props.className
25 | */
26 | export default function Input(props: InputProps) {
27 | return (
28 |
29 |
30 |
31 |
42 |
43 |
44 | {props.error && (
45 |
51 | {props.error.message}
52 |
53 | )}
54 |
55 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Input/Input/index.ts:
--------------------------------------------------------------------------------
1 | export {default as Input} from './Input';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Input/MarkdownEditor/extensions/Button.tsx:
--------------------------------------------------------------------------------
1 | import {mergeAttributes, Node, wrappingInputRule} from '@tiptap/core';
2 |
3 | export type colors = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'indigo' | 'purple' | 'pink' | 'black';
4 |
5 | // Map each color to a tailwind color hex code for 500
6 | const colorMap = {
7 | red: '#ef4444',
8 | orange: '#f97316',
9 | yellow: '#facc15',
10 | green: '#22c55e',
11 | blue: '#2563eb',
12 | indigo: '#6366f1',
13 | purple: '#8b5cf6',
14 | pink: '#ec4899',
15 | black: '#171717',
16 | } as const;
17 |
18 | export interface ButtonOptions {
19 | HTMLAttributes: Record;
20 | }
21 |
22 | declare module '@tiptap/core' {
23 | interface Commands {
24 | button: {
25 | /**
26 | * Set a blockquote node
27 | */
28 | setButton: (attributes: {href: string; color: colors}) => ReturnType;
29 | /**
30 | * Toggle a blockquote node
31 | */
32 | toggleButton: (attributes: {href: string; color: colors}) => ReturnType;
33 | };
34 | }
35 | }
36 |
37 | export const inputRegex = /^\s*>\s$/;
38 |
39 | export const Button = Node.create({
40 | name: 'button',
41 | content: 'text*',
42 | marks: '',
43 | group: 'block',
44 | defining: true,
45 |
46 | addOptions() {
47 | return {
48 | HTMLAttributes: {
49 | class: 'btn',
50 | },
51 | };
52 | },
53 |
54 | addAttributes() {
55 | return {
56 | href: {
57 | default: null,
58 | },
59 | color: {
60 | default: 'blue' as colors,
61 | },
62 | };
63 | },
64 |
65 | parseHTML() {
66 | return [
67 | {
68 | tag: 'a.btn',
69 | priority: 51,
70 | },
71 | ];
72 | },
73 |
74 | renderHTML({node, HTMLAttributes}) {
75 | return [
76 | 'a',
77 | mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
78 | style: `color: white; background-color: ${
79 | colorMap[node.attrs.color as colors]
80 | }; text-align: center; text-decoration: none; padding: 12px; border-radius: 8px; display: block; font-size: 15px; line-height: 20px; font-weight: 600; margin: 9px 0 9px 0;`,
81 | }),
82 | 0,
83 | ];
84 | },
85 |
86 | addCommands() {
87 | return {
88 | setButton:
89 | attributes =>
90 | ({commands}) => {
91 | return commands.setNode(this.name, attributes);
92 | },
93 | toggleButton:
94 | attributes =>
95 | ({commands}) => {
96 | return commands.toggleNode(this.name, 'paragraph', attributes);
97 | },
98 | };
99 | },
100 |
101 | addInputRules() {
102 | return [
103 | wrappingInputRule({
104 | find: inputRegex,
105 | type: this.type,
106 | }),
107 | ];
108 | },
109 | });
110 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Input/MarkdownEditor/extensions/ColorSelector.tsx:
--------------------------------------------------------------------------------
1 | import {Editor} from '@tiptap/core';
2 | import cx from 'classnames';
3 | import {Check, ChevronDown} from 'lucide-react';
4 | import {FC} from 'react';
5 |
6 | export interface BubbleColorMenuItem {
7 | name: string;
8 | color: string;
9 | }
10 |
11 | interface ColorSelectorProps {
12 | editor: Editor;
13 | isOpen: boolean;
14 | setIsOpen: (isOpen: boolean) => void;
15 | }
16 |
17 | export const ColorSelector: FC = ({editor, isOpen, setIsOpen}) => {
18 | const items: BubbleColorMenuItem[] = [
19 | {
20 | name: 'Default',
21 | color: '#000000',
22 | },
23 | {
24 | name: 'Purple',
25 | color: '#9333EA',
26 | },
27 | {
28 | name: 'Red',
29 | color: '#E00000',
30 | },
31 | {
32 | name: 'Blue',
33 | color: '#2563EB',
34 | },
35 | {
36 | name: 'Green',
37 | color: '#008A00',
38 | },
39 | {
40 | name: 'Orange',
41 | color: '#FFA500',
42 | },
43 | {
44 | name: 'Pink',
45 | color: '#BA4081',
46 | },
47 | {
48 | name: 'Gray',
49 | color: '#A8A29E',
50 | },
51 | ];
52 |
53 | const activeItem = items.find(({color}) => editor.isActive('textStyle', {color}));
54 |
55 | return (
56 |
57 |
68 |
69 | {isOpen && (
70 |
71 | {items.map(({name, color}, index) => (
72 |
93 | ))}
94 |
95 | )}
96 |
97 | );
98 | };
99 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Input/MarkdownEditor/extensions/EditorBubbleMenu.tsx:
--------------------------------------------------------------------------------
1 | import {BubbleMenu, BubbleMenuProps} from '@tiptap/react';
2 | import cx from 'classnames';
3 | import {FC, useState} from 'react';
4 | import {BoldIcon, ItalicIcon, StrikethroughIcon} from 'lucide-react';
5 |
6 | import {NodeSelector} from './NodeSelector';
7 | import {ColorSelector} from './ColorSelector';
8 |
9 | export interface BubbleMenuItem {
10 | name: string;
11 | isActive: () => boolean;
12 | command: () => void;
13 | icon: typeof BoldIcon;
14 | }
15 |
16 | type EditorBubbleMenuProps = Omit & {
17 | items: BubbleMenuItem[];
18 | };
19 |
20 | export const EditorBubbleMenu: FC = props => {
21 | const items: BubbleMenuItem[] = [
22 | {
23 | name: 'bold',
24 | isActive: () => props.editor?.isActive('bold') ?? false,
25 | command: () => props.editor?.chain().focus().toggleBold().run(),
26 | icon: BoldIcon,
27 | },
28 | {
29 | name: 'italic',
30 | isActive: () => props.editor?.isActive('italic') ?? false,
31 | command: () => props.editor?.chain().focus().toggleItalic().run(),
32 | icon: ItalicIcon,
33 | },
34 |
35 | {
36 | name: 'strike',
37 | isActive: () => props.editor?.isActive('strike') ?? false,
38 | command: () => props.editor?.chain().focus().toggleStrike().run(),
39 | icon: StrikethroughIcon,
40 | },
41 | ...props.items,
42 | ];
43 |
44 | const bubbleMenuProps: EditorBubbleMenuProps = {
45 | ...props,
46 | shouldShow: ({editor}) => {
47 | // don't show if image is selected
48 | if (editor.isActive('image')) {
49 | return false;
50 | }
51 | return editor.view.state.selection.content().size > 0;
52 | },
53 | tippyOptions: {
54 | moveTransition: 'transform 0.15s ease-out',
55 | onHidden: () => {
56 | setIsNodeSelectorOpen(false);
57 | setIsColorSelectorOpen(false);
58 | },
59 | },
60 | };
61 |
62 | const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
63 | const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
64 |
65 | return (
66 |
70 | {props.editor && (
71 |
72 | )}
73 |
74 | {items.map((item, index) => (
75 |
89 | ))}
90 |
91 | {props.editor && (
92 |
93 | )}
94 |
95 | );
96 | };
97 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Input/MarkdownEditor/extensions/MetadataSuggestion/MetadataSuggestion.tsx:
--------------------------------------------------------------------------------
1 | import {mergeAttributes, Node} from '@tiptap/core';
2 | import {Node as ProseMirrorNode} from '@tiptap/pm/model';
3 | import {PluginKey} from '@tiptap/pm/state';
4 | import Suggestion, {SuggestionOptions} from '@tiptap/suggestion';
5 |
6 | export interface MentionOptions {
7 | HTMLAttributes: Record;
8 | renderLabel: (props: {options: MentionOptions; node: ProseMirrorNode}) => string;
9 | suggestion: Omit;
10 | }
11 |
12 | export const MentionPluginKey = new PluginKey('mention');
13 |
14 | export const Mention = Node.create({
15 | name: 'mention',
16 |
17 | addOptions() {
18 | return {
19 | HTMLAttributes: {},
20 | renderLabel({options, node}) {
21 | return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
22 | },
23 | suggestion: {
24 | char: '{{',
25 | pluginKey: MentionPluginKey,
26 | command: ({editor, range, props}) => {
27 | // increase range.to by one when the next node is of type "text"
28 | // and starts with a space character
29 | const nodeAfter = editor.view.state.selection.$to.nodeAfter;
30 | const overrideSpace = nodeAfter?.text?.startsWith(' ');
31 |
32 | if (overrideSpace) {
33 | range.to += 1;
34 | }
35 |
36 | editor.chain().focus().insertContent(`${props.id}}}`).run();
37 |
38 | window.getSelection()?.collapseToEnd();
39 | },
40 | allow: ({state, range}) => {
41 | const $from = state.doc.resolve(range.from);
42 | const type = state.schema.nodes[this.name];
43 | const allow = !!$from.parent.type.contentMatch.matchType(type);
44 |
45 | return allow;
46 | },
47 | },
48 | };
49 | },
50 |
51 | group: 'inline',
52 |
53 | inline: true,
54 |
55 | selectable: true,
56 |
57 | atom: true,
58 |
59 | addAttributes() {
60 | return {
61 | id: {
62 | default: null,
63 | parseHTML: element => element.getAttribute('data-id'),
64 | renderHTML: attributes => {
65 | if (!attributes.id) {
66 | return {};
67 | }
68 |
69 | return {
70 | 'data-id': attributes.id,
71 | };
72 | },
73 | },
74 |
75 | label: {
76 | default: null,
77 | parseHTML: element => element.getAttribute('data-label'),
78 | renderHTML: attributes => {
79 | if (!attributes.label) {
80 | return {};
81 | }
82 |
83 | return {
84 | 'data-label': attributes.label,
85 | };
86 | },
87 | },
88 | };
89 | },
90 |
91 | parseHTML() {
92 | return [
93 | {
94 | tag: `span[data-type="${this.name}"]`,
95 | },
96 | ];
97 | },
98 |
99 | renderHTML({node, HTMLAttributes}) {
100 | return [
101 | 'span',
102 | mergeAttributes({'data-type': this.name}, this.options.HTMLAttributes, HTMLAttributes),
103 | this.options.renderLabel({
104 | options: this.options,
105 | node,
106 | }),
107 | ];
108 | },
109 |
110 | renderText({node}) {
111 | return this.options.renderLabel({
112 | options: this.options,
113 | node,
114 | });
115 | },
116 |
117 | addKeyboardShortcuts() {
118 | return {
119 | Backspace: () =>
120 | this.editor.commands.command(({tr, state}) => {
121 | let isMention = false;
122 | const {selection} = state;
123 | const {empty, anchor} = selection;
124 |
125 | if (!empty) {
126 | return false;
127 | }
128 |
129 | state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
130 | if (node.type.name === this.name) {
131 | isMention = true;
132 | tr.insertText(this.options.suggestion.char ?? '', pos, pos + node.nodeSize);
133 |
134 | return false;
135 | }
136 | });
137 |
138 | return isMention;
139 | }),
140 | };
141 | },
142 |
143 | addProseMirrorPlugins() {
144 | return [
145 | Suggestion({
146 | editor: this.editor,
147 | ...this.options.suggestion,
148 | }),
149 | ];
150 | },
151 | });
152 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Input/MarkdownEditor/extensions/MetadataSuggestion/SuggestionList.tsx:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 |
3 | import React, { forwardRef, useEffect, useImperativeHandle, useState } from "react";
4 |
5 | export default forwardRef((props, ref) => {
6 | const [selectedIndex, setSelectedIndex] = useState(0);
7 |
8 | const selectItem = (index) => {
9 | const item = props.items[index];
10 |
11 | if (item) {
12 | props.command({ id: item });
13 | }
14 | };
15 |
16 | const upHandler = () => {
17 | setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
18 | };
19 |
20 | const downHandler = () => {
21 | setSelectedIndex((selectedIndex + 1) % props.items.length);
22 | };
23 |
24 | const enterHandler = () => {
25 | selectItem(selectedIndex);
26 | };
27 |
28 | useEffect(() => setSelectedIndex(0), [props.items]);
29 |
30 | useImperativeHandle(ref, () => ({
31 | onKeyDown: ({ event }) => {
32 | if (event.key === "ArrowUp") {
33 | upHandler();
34 | return true;
35 | }
36 |
37 | if (event.key === "ArrowDown") {
38 | downHandler();
39 | return true;
40 | }
41 |
42 | if (event.key === "Enter") {
43 | enterHandler();
44 | return true;
45 | }
46 |
47 | return false;
48 | },
49 | }));
50 |
51 | return (
52 |
53 | {props.items.length ? (
54 | props.items.map((item, index) => (
55 |
64 | ))
65 | ) : (
66 |
67 | No result
68 |
69 | )}
70 |
71 | );
72 | });
73 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Input/MarkdownEditor/extensions/MetadataSuggestion/Suggestions.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 |
3 | import { ReactRenderer } from "@tiptap/react";
4 | import { network } from "dashboard/src/lib/network";
5 | import type { RefAttributes } from "react";
6 | import tippy from "tippy.js";
7 | import MentionList from "./SuggestionList";
8 |
9 | export default {
10 | items: async ({ query }: { query: string }) => {
11 | const activeProject = typeof window !== "undefined" ? window.localStorage.getItem("project") : null;
12 |
13 | if (!activeProject) {
14 | return [];
15 | }
16 |
17 | const keys = await network.fetch("GET", `/projects/id/${activeProject}/contacts/metadata`);
18 |
19 | return keys.filter((key) => key.toLowerCase().includes(query.toLowerCase()));
20 | },
21 |
22 | render: () => {
23 | let component: ReactRenderer>;
24 | let popup: { destroy: () => void }[];
25 |
26 | return {
27 | onStart: (props: { editor: any; clientRect: any }) => {
28 | component = new ReactRenderer(MentionList, {
29 | props,
30 | editor: props.editor,
31 | });
32 |
33 | if (!props.clientRect) {
34 | return;
35 | }
36 |
37 | popup = tippy("body", {
38 | getReferenceClientRect: props.clientRect,
39 | appendTo: () => document.body,
40 | content: component.element,
41 | showOnCreate: true,
42 | interactive: true,
43 | trigger: "manual",
44 | placement: "bottom-start",
45 | });
46 | },
47 |
48 | onUpdate(props) {
49 | component.updateProps(props);
50 |
51 | if (!props.clientRect) {
52 | return;
53 | }
54 |
55 | popup[0].setProps({
56 | getReferenceClientRect: props.clientRect,
57 | });
58 | },
59 |
60 | onKeyDown(props) {
61 | if (props.event.key === "Escape") {
62 | popup[0].hide();
63 |
64 | return true;
65 | }
66 |
67 | return component.ref?.onKeyDown(props);
68 | },
69 |
70 | onExit() {
71 | if (popup[0]) {
72 | popup[0].destroy();
73 | }
74 |
75 | component.destroy();
76 | },
77 | };
78 | },
79 | };
80 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Input/MarkdownEditor/extensions/NodeSelector.tsx:
--------------------------------------------------------------------------------
1 | import {Editor} from '@tiptap/core';
2 | import cx from 'classnames';
3 | import {Check, ChevronDown, Heading1, Heading2, Heading3, ListOrdered, TextIcon} from 'lucide-react';
4 | import {FC} from 'react';
5 |
6 | import {BubbleMenuItem} from './EditorBubbleMenu';
7 |
8 | interface NodeSelectorProps {
9 | editor: Editor;
10 | isOpen: boolean;
11 | setIsOpen: (isOpen: boolean) => void;
12 | }
13 |
14 | export const NodeSelector: FC = ({editor, isOpen, setIsOpen}) => {
15 | const items: BubbleMenuItem[] = [
16 | {
17 | name: 'Text',
18 | icon: TextIcon,
19 | command: () => editor.chain().focus().toggleNode('paragraph', 'paragraph').run(),
20 | isActive: () => editor.isActive('paragraph') && !editor.isActive('bulletList') && !editor.isActive('orderedList'),
21 | },
22 | {
23 | name: 'Heading 1',
24 | icon: Heading1,
25 | command: () => editor.chain().focus().toggleHeading({level: 1}).run(),
26 | isActive: () => editor.isActive('heading', {level: 1}),
27 | },
28 | {
29 | name: 'Heading 2',
30 | icon: Heading2,
31 | command: () => editor.chain().focus().toggleHeading({level: 2}).run(),
32 | isActive: () => editor.isActive('heading', {level: 2}),
33 | },
34 | {
35 | name: 'Heading 3',
36 | icon: Heading3,
37 | command: () => editor.chain().focus().toggleHeading({level: 3}).run(),
38 | isActive: () => editor.isActive('heading', {level: 3}),
39 | },
40 | {
41 | name: 'Bullet List',
42 | icon: ListOrdered,
43 | command: () => editor.chain().focus().toggleBulletList().run(),
44 | isActive: () => editor.isActive('bulletList'),
45 | },
46 | {
47 | name: 'Numbered List',
48 | icon: ListOrdered,
49 | command: () => editor.chain().focus().toggleOrderedList().run(),
50 | isActive: () => editor.isActive('orderedList'),
51 | },
52 | ];
53 |
54 | const activeItem = items.find(item => item.isActive());
55 |
56 | return (
57 |
58 |
69 |
70 | {isOpen && (
71 |
72 | {items.map((item, index) => (
73 |
95 | ))}
96 |
97 | )}
98 |
99 | );
100 | };
101 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Input/MarkdownEditor/extensions/Progress.tsx:
--------------------------------------------------------------------------------
1 | import {Node} from '@tiptap/core';
2 |
3 | export type colors = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'indigo' | 'purple' | 'pink' | 'black';
4 |
5 | // Map each color to a tailwind color hex code for 500
6 | const colorMap = {
7 | red: '#ef4444',
8 | orange: '#f97316',
9 | yellow: '#facc15',
10 | green: '#22c55e',
11 | blue: '#2563eb',
12 | indigo: '#6366f1',
13 | purple: '#8b5cf6',
14 | pink: '#ec4899',
15 | black: '#171717',
16 | } as const;
17 |
18 | export interface ProgressOptions {
19 | percent: number;
20 | color: colors;
21 | }
22 |
23 | declare module '@tiptap/core' {
24 | interface Commands {
25 | progress: {
26 | /**
27 | * Set a heading node
28 | */
29 | setProgress: (attributes: {percent: number; color: colors}) => ReturnType;
30 | /**
31 | * Toggle a heading node
32 | */
33 | toggleProgress: (attributes: {percent: number; color: colors}) => ReturnType;
34 | };
35 | }
36 | }
37 |
38 | export const Progress = Node.create({
39 | name: 'progress',
40 |
41 | content: 'inline*',
42 |
43 | group: 'block',
44 |
45 | defining: true,
46 |
47 | addAttributes() {
48 | return {
49 | percent: {
50 | default: 100,
51 | rendered: false,
52 | },
53 | color: {
54 | default: 'blue' as colors,
55 | rendered: false,
56 | },
57 | };
58 | },
59 |
60 | parseHTML() {
61 | return [
62 | {
63 | tag: 'table',
64 | getAttrs: element => {
65 | // @ts-ignore
66 | const percent = element.querySelector('td')?.style.width;
67 | // @ts-ignore
68 | const color = element.querySelector('td')?.style.backgroundColor;
69 |
70 | const rgb = color?.slice(4, color.length - 1).split(', ');
71 | const hex = rgb?.map((value: any) => {
72 | const hex = Number(value).toString(16);
73 | return hex.length === 1 ? '0' + hex : hex;
74 | });
75 |
76 | return {
77 | percent: Number(percent?.slice(0, percent.length - 1)),
78 | color: Object.keys(colorMap).find(key => colorMap[key as colors] === `#${hex?.join('')}`) as colors,
79 | };
80 | },
81 | },
82 | ];
83 | },
84 |
85 | renderHTML({node}) {
86 | // Render a progress bar using table elements
87 | return [
88 | 'table',
89 | {
90 | class: 'progress',
91 | style: `width: 100%; border-radius: 10px;height: 28px;`,
92 | },
93 | [
94 | 'tr',
95 | {
96 | style: `width: 100%; border-radius: 8px;`,
97 | },
98 | // Render two cells, one for the progress bar and one for the percentage
99 | [
100 | 'td',
101 | {
102 | style: `width: ${node.attrs.percent}%; background-color: ${
103 | colorMap[node.attrs.color as colors]
104 | }; border-top-left-radius: 8px; border-bottom-left-radius: 8px;`,
105 | },
106 | ],
107 | [
108 | 'td',
109 | {
110 | style: `width: ${
111 | 100 - node.attrs.percent
112 | }%; background-color: #f5f5f5; border-top-right-radius: 8px; border-bottom-right-radius: 8px;`,
113 | },
114 | ],
115 | ],
116 | ];
117 | },
118 |
119 | addCommands() {
120 | return {
121 | setProgress:
122 | attributes =>
123 | ({commands}) => {
124 | return commands.setNode(this.name, attributes);
125 | },
126 | toggleProgress:
127 | attributes =>
128 | ({commands}) => {
129 | return commands.toggleNode(this.name, 'paragraph', attributes);
130 | },
131 | };
132 | },
133 | });
134 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Input/MarkdownEditor/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as Editor} from './Editor';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Input/MultiselectDropdown/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as MultiselectDropdown} from './MultiselectDropdown';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Input/Toggle/Toggle.tsx:
--------------------------------------------------------------------------------
1 | export interface ToggleProps {
2 | title: string;
3 | description: string;
4 | toggled: boolean;
5 | onToggle: () => void;
6 | disabled?: boolean;
7 | className?: string;
8 | }
9 |
10 | /**
11 | * @param root0
12 | * @param root0.toggled
13 | * @param root0.onToggle
14 | * @param root0.title
15 | * @param root0.description
16 | * @param root0.className
17 | * @param root0.disabled
18 | */
19 | export default function Toggle({title, description, toggled, onToggle, disabled, className}: ToggleProps) {
20 | return (
21 | <>
22 |
23 |
24 |
29 | {title}
30 |
31 | {description}
32 |
33 |
51 |
52 | >
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Input/Toggle/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as Toggle} from './Toggle';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Input/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './Toggle';
2 | export * from './Dropdown';
3 | export * from './MultiselectDropdown';
4 | export * from './MarkdownEditor';
5 | export * from './Input';
6 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Navigation/AnalyticsTabs/AnalyticsTabs.tsx:
--------------------------------------------------------------------------------
1 | import {Tabs} from '../Tabs';
2 | import React from 'react';
3 | import {useRouter} from 'next/router';
4 |
5 | /**
6 | *
7 | * @param root0
8 | * @param root0.onMethodChange
9 | */
10 | export default function AnalyticsTabs() {
11 | const router = useRouter();
12 |
13 | const links = [
14 | {to: '/analytics', text: 'Overview', active: router.route === '/analytics'},
15 | {to: '/analytics/clicks', text: 'Clicks', active: router.route === '/analytics/clicks'},
16 | ];
17 |
18 | return ;
19 | }
20 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Navigation/AnalyticsTabs/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as AnalyticsTabs} from './AnalyticsTabs';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Navigation/DeveloperTabs/DeveloperTabs.tsx:
--------------------------------------------------------------------------------
1 | import {Tabs} from '../Tabs';
2 | import React from 'react';
3 | import {useRouter} from 'next/router';
4 |
5 | /**
6 | *
7 | */
8 | export default function DeveloperTabs() {
9 | const router = useRouter();
10 |
11 | const links = [{to: '/developers/webhooks', text: 'Webhooks', active: router.route === '/developers/webhooks'}];
12 |
13 | return ;
14 | }
15 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Navigation/DeveloperTabs/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as DeveloperTabs} from './DeveloperTabs';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Navigation/ProjectSelector/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as ProjectSelector} from './ProjectSelector';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Navigation/SettingTabs/SettingTabs.tsx:
--------------------------------------------------------------------------------
1 | import {Tabs} from '../Tabs';
2 | import React from 'react';
3 | import {useRouter} from 'next/router';
4 |
5 | /**
6 | *
7 | */
8 | export default function SettingTabs() {
9 | const router = useRouter();
10 |
11 | const links = [
12 | {to: '/settings/project', text: 'Project Settings', active: router.route === '/settings/project'},
13 | {to: '/settings/api', text: 'API Keys', active: router.route === '/settings/api'},
14 | {to: '/settings/identity', text: 'Verified Domain', active: router.route === '/settings/identity'},
15 | {to: '/settings/members', text: 'Members', active: router.route === '/settings/members'},
16 | ];
17 |
18 | return ;
19 | }
20 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Navigation/SettingTabs/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as SettingTabs} from './SettingTabs';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Navigation/Sidebar/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as Sidebar} from './Sidebar';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Navigation/Tabs/Tabs.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import {useRouter} from 'next/router';
3 |
4 | export interface TabProps {
5 | links: {
6 | to: string;
7 | text: string;
8 | active: boolean;
9 | }[];
10 | }
11 |
12 | /**
13 | * @param root0
14 | * @param root0.links
15 | */
16 | export default function Tabs({links}: TabProps) {
17 | const router = useRouter();
18 | return (
19 |
20 |
21 |
24 |
38 |
39 |
40 |
41 |
57 |
58 |
59 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Navigation/Tabs/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as Tabs} from './Tabs';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Navigation/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './Sidebar';
2 | export * from './ProjectSelector';
3 | export * from './Tabs';
4 | export * from './SettingTabs';
5 | export * from './AnalyticsTabs';
6 | export * from './DeveloperTabs';
7 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Overlay/Modal/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as Modal} from './Modal';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Overlay/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './Modal/index';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Skeleton/Skeleton.tsx:
--------------------------------------------------------------------------------
1 | export interface SkeletonProps {
2 | type: 'table' | 'card';
3 | }
4 |
5 | /**
6 | *
7 | * @param root0
8 | * @param root0.type
9 | */
10 | export default function Skeleton({type}: SkeletonProps) {
11 | if (type === 'table') {
12 | return (
13 | <>
14 |
18 |
25 |
32 |
39 |
46 |
53 |
60 |
67 |
74 |
Loading...
75 |
76 | >
77 | );
78 | }
79 |
80 | return <>>;
81 | }
82 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Skeleton/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as Skeleton} from './Skeleton';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Table/Table.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export interface TableProps {
4 | values: {
5 | // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
6 | [key: string]: string | number | boolean | Date | React.ReactNode | null;
7 | }[];
8 | }
9 |
10 | /**
11 | * @param root0
12 | * @param root0.values
13 | */
14 | export default function Table({values}: TableProps) {
15 | if (values.length === 0) {
16 | return No values provided
;
17 | }
18 |
19 | return (
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | {Object.keys(values[0]).map(header => {
28 | return (
29 |
35 | {header}
36 | |
37 | );
38 | })}
39 |
40 |
41 |
42 | {values.map(row => {
43 | return (
44 |
45 | {Object.entries(row).map(value => {
46 | if (value[1] === null || value[1] === undefined) {
47 | return (
48 | Not specified |
49 | );
50 | }
51 |
52 | if (typeof value[1] === 'boolean') {
53 | return (
54 |
55 | {value[1] ? (
56 |
69 | ) : (
70 |
92 | )}
93 | |
94 | );
95 | }
96 |
97 | // @ts-ignore
98 | return {value[1]} | ;
99 | })}
100 |
101 | );
102 | })}
103 |
104 |
105 |
106 |
107 |
108 |
109 | );
110 | }
111 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Table/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as Table} from './Table';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Utility/Empty/Empty.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Ghost} from 'lucide-react';
3 |
4 | export interface EmptyProps {
5 | title: string;
6 | description: string;
7 | icon?: React.ReactNode;
8 | }
9 |
10 | /**
11 | * @param root0
12 | * @param root0.title
13 | * @param root0.description
14 | * @param root0.icon
15 | */
16 | export default function Empty({title, description, icon}: EmptyProps) {
17 | return (
18 |
19 |
33 | {title}
34 | {description}
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Utility/Empty/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as Empty} from './Empty';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Utility/FullscreenLoader/FullscreenLoader.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | */
4 | import { LineWobble } from "@uiball/loaders";
5 |
6 | /**
7 | *
8 | */
9 | export default function FullscreenLoader() {
10 | return (
11 |
12 |
13 |
14 | Loading...
15 |
16 |
17 | Does this take longer than expected? Try clearing your browser's cache or check if you have an ad blocker enabled!
18 |
19 |
20 |
21 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Utility/FullscreenLoader/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as FullscreenLoader} from './FullscreenLoader';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Utility/ProgressBar/ProgressBar.tsx:
--------------------------------------------------------------------------------
1 | import {motion} from 'framer-motion';
2 | import React from 'react';
3 |
4 | export interface ProgressBarProps {
5 | percentage: number;
6 | }
7 |
8 | /**
9 | * @param root0
10 | * @param root0.percentage
11 | */
12 | export default function ProgressBar({percentage}: ProgressBarProps) {
13 | const formattedPercentage = isNaN(percentage) ? 0 : percentage;
14 |
15 | return (
16 |
17 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Utility/ProgressBar/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as ProgressBar} from './ProgressBar';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Utility/Redirect/Redirect.tsx:
--------------------------------------------------------------------------------
1 | import {useEffect} from 'react';
2 | import {useRouter} from 'next/router';
3 |
4 | export interface RedirectProps {
5 | to: string;
6 | }
7 |
8 | /**
9 | * @param root0
10 | * @param root0.to
11 | */
12 | export default function Redirect({to}: RedirectProps) {
13 | const router = useRouter();
14 |
15 | useEffect(() => {
16 | void router.push(to);
17 | }, []);
18 |
19 | return null;
20 | }
21 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Utility/Redirect/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as Redirect} from './Redirect';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Utility/Tooltip/Tooltip.tsx:
--------------------------------------------------------------------------------
1 | import Tippy from '@tippyjs/react';
2 | import React, {ReactNode} from 'react';
3 |
4 | export interface TooltipProps {
5 | content: ReactNode | string;
6 | icon: ReactNode;
7 | }
8 |
9 | /**
10 | *
11 | * @param root0
12 | * @param root0.content
13 | * @param root0.icon
14 | */
15 | export default function Tooltip({content, icon}: TooltipProps) {
16 | return (
17 | <>
18 | {content}}
22 | >
23 |
34 |
35 | >
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Utility/Tooltip/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as Tooltip} from './Tooltip';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/Utility/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './Redirect';
2 | export * from './FullscreenLoader';
3 | export * from './Empty';
4 | export * from './ProgressBar';
5 | export * from './Tooltip';
6 |
--------------------------------------------------------------------------------
/packages/dashboard/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Input';
2 | export * from './Utility';
3 | export * from './Alert';
4 | export * from './Badge';
5 | export * from './Table';
6 | export * from './Overlay';
7 | export * from './Navigation';
8 | export * from './Card';
9 | export * from './Skeleton';
10 | export * from './CodeBlock';
11 |
--------------------------------------------------------------------------------
/packages/dashboard/src/layouts/Dashboard.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {FullscreenLoader, Redirect, Sidebar} from '../components';
3 | import {useActiveProject, useProjects} from '../lib/hooks/projects';
4 | import {useUser} from '../lib/hooks/users';
5 | import {AnimatePresence, motion} from 'framer-motion';
6 | import {useRouter} from 'next/router';
7 |
8 | export const Dashboard = (props: {children: React.ReactNode}) => {
9 | const router = useRouter();
10 | const activeProject = useActiveProject();
11 | const {data: projects} = useProjects();
12 | const {data: user} = useUser();
13 |
14 | const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
15 |
16 | if (!projects || !user || !activeProject) {
17 | return ;
18 | }
19 |
20 | if (projects.length === 0) {
21 | return ;
22 | }
23 |
24 | return (
25 | <>
26 |
27 |
setMobileSidebarOpen(!mobileSidebarOpen)}
30 | />
31 |
32 |
33 |
49 |
50 |
51 |
52 |
53 |
54 |
62 | {props.children}
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | >
71 | );
72 | };
73 |
--------------------------------------------------------------------------------
/packages/dashboard/src/layouts/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Dashboard';
2 |
--------------------------------------------------------------------------------
/packages/dashboard/src/lib/atoms/project.ts:
--------------------------------------------------------------------------------
1 | import {atom} from 'jotai';
2 |
3 | export const atomActiveProject = atom(
4 | typeof window !== 'undefined' ? window.localStorage.getItem('project') : null,
5 | );
6 |
--------------------------------------------------------------------------------
/packages/dashboard/src/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export const API_URI = process.env.NEXT_PUBLIC_API_URI ?? 'http://localhost:8080';
2 | export const AWS_REGION = process.env.NEXT_PUBLIC_AWS_REGION;
3 |
4 | export const NO_AUTH_ROUTES = ['/auth/signup', '/auth/login', '/auth/reset', '/unsubscribe/[id]', '/subscribe/[id]'];
5 |
--------------------------------------------------------------------------------
/packages/dashboard/src/lib/hooks/actions.ts:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr';
2 | import {Action, Email, Event, Task, Template, Trigger} from '@prisma/client';
3 | import {useActiveProject} from './projects';
4 |
5 | /**
6 | *
7 | * @param id
8 | */
9 | export function useAction(id: string) {
10 | return useSWR(`/v1/actions/${id}`);
11 | }
12 |
13 | /**
14 | *
15 | * @param id
16 | */
17 | export function useRelatedActions(id: string) {
18 | return useSWR<
19 | (Action & {
20 | events: Event[];
21 | notevents: Event[];
22 | triggers: Trigger[];
23 | emails: Email[];
24 | template: Template;
25 | })[]
26 | >(`/v1/actions/${id}/related`);
27 | }
28 |
29 | /**
30 | *
31 | */
32 | export function useActions() {
33 | const activeProject = useActiveProject();
34 |
35 | return useSWR<
36 | (Action & {
37 | triggers: Trigger[];
38 | template: Template;
39 | emails: Email[];
40 | tasks: Task[];
41 | })[]
42 | >(activeProject ? `/projects/id/${activeProject.id}/actions` : null);
43 | }
44 |
--------------------------------------------------------------------------------
/packages/dashboard/src/lib/hooks/analytics.ts:
--------------------------------------------------------------------------------
1 | import {useActiveProject} from './projects';
2 | import useSWR from 'swr';
3 |
4 | /**
5 |
6 | * @param method
7 | */
8 | export function useAnalytics(method?: 'week' | 'month' | 'year') {
9 | const activeProject = useActiveProject();
10 |
11 | return useSWR<{
12 | contacts: {
13 | timeseries: {
14 | day: Date;
15 | count: number;
16 | }[];
17 | subscribed: number;
18 | unsubscribed: number;
19 | };
20 | emails: {
21 | total: number;
22 | bounced: number;
23 | opened: number;
24 | complaint: number;
25 | totalPrev: number;
26 | bouncedPrev: number;
27 | openedPrev: number;
28 | complaintPrev: number;
29 | };
30 | clicks: {
31 | actions: {link: string; name: string; count: number}[];
32 | };
33 | }>(activeProject ? `/projects/id/${activeProject.id}/analytics?method=${method ?? 'week'}` : null);
34 | }
35 |
--------------------------------------------------------------------------------
/packages/dashboard/src/lib/hooks/campaigns.ts:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr';
2 | import {Campaign} from '@prisma/client';
3 | import {useActiveProject} from './projects';
4 |
5 | /**
6 | *
7 | * @param id
8 | */
9 | export function useCampaign(id: string) {
10 | return useSWR(`/v1/campaigns/${id}`);
11 | }
12 |
13 | /**
14 | *
15 | */
16 | export function useCampaigns() {
17 | const activeProject = useActiveProject();
18 |
19 | return useSWR<
20 | (Campaign & {
21 | emails: {
22 | id: string;
23 | status: string;
24 | }[];
25 | tasks: {
26 | id: string;
27 | }[];
28 | recipients: {
29 | id: string;
30 | }[];
31 | })[]
32 | >(activeProject ? `/projects/id/${activeProject.id}/campaigns` : null);
33 | }
34 |
--------------------------------------------------------------------------------
/packages/dashboard/src/lib/hooks/contacts.ts:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr';
2 | import {Action, Contact, Email, Event, Project, Trigger} from '@prisma/client';
3 | import {useActiveProject} from './projects';
4 |
5 | export interface WithProject {
6 | id: string;
7 | withProject: true;
8 | }
9 |
10 | export interface WithoutProject {
11 | id: string;
12 | withProject?: false;
13 | }
14 |
15 | export type WithOrWithoutProject = T extends WithProject
16 | ?
17 | | (Contact & {
18 | emails: Email[];
19 | triggers: (Trigger & {
20 | event: Event | null;
21 | action: Action | null;
22 | })[];
23 | project: Project;
24 | })
25 | | null
26 | :
27 | | (Contact & {
28 | emails: Email[];
29 | triggers: (Trigger & {
30 | event: Event | null;
31 | action: Action | null;
32 | })[];
33 | })
34 | | null;
35 |
36 | /**
37 | *
38 | * @param id.id
39 | * @param id
40 | * @param id.withProject
41 | */
42 | export function useContact({id, withProject = false}: T) {
43 | return useSWR>(withProject ? `/v1/contacts/${id}?withProject=true` : `/v1/contacts/${id}`);
44 | }
45 |
46 | /**
47 | *
48 | * @param page
49 | */
50 | export function useContacts(page: number) {
51 | const activeProject = useActiveProject();
52 |
53 | return useSWR<{
54 | contacts: (Contact & {
55 | triggers: Trigger[];
56 | })[];
57 | count: number;
58 | }>(activeProject ? `/projects/id/${activeProject.id}/contacts?page=${page}` : null);
59 | }
60 |
61 | /**
62 | *
63 | */
64 | export function useContactsCount() {
65 | const activeProject = useActiveProject();
66 |
67 | return useSWR(activeProject ? `/projects/id/${activeProject.id}/contacts/count` : null);
68 | }
69 |
70 | /**
71 | *
72 | */
73 | export function useContactMetadata() {
74 | const activeProject = useActiveProject();
75 |
76 | return useSWR(activeProject ? `/projects/id/${activeProject.id}/contacts/metadata` : null);
77 | }
78 |
79 | /**
80 | *
81 | * @param query
82 | */
83 | export function searchContacts(query: string | undefined) {
84 | const activeProject = useActiveProject();
85 |
86 | if (!query) {
87 | return useSWR<{
88 | contacts: (Contact & {
89 | triggers: Trigger[];
90 | emails: Email[];
91 | })[];
92 | count: number;
93 | }>(activeProject ? `/projects/id/${activeProject.id}/contacts` : null);
94 | }
95 |
96 | return useSWR<{
97 | contacts: (Contact & {
98 | triggers: Trigger[];
99 | emails: Email[];
100 | })[];
101 | count: number;
102 | }>(activeProject ? `/projects/id/${activeProject.id}/contacts/search?query=${query}` : null, {
103 | revalidateOnFocus: false,
104 | refreshInterval: 0,
105 | });
106 | }
107 |
--------------------------------------------------------------------------------
/packages/dashboard/src/lib/hooks/emails.ts:
--------------------------------------------------------------------------------
1 | import {useActiveProject} from './projects';
2 | import useSWR from 'swr';
3 | import {Email} from '@prisma/client';
4 |
5 | /**
6 | *
7 | */
8 | export function useEmails() {
9 | const activeProject = useActiveProject();
10 |
11 | return useSWR(activeProject ? `/projects/id/${activeProject.id}/emails` : null);
12 | }
13 |
14 | /**
15 | *
16 | */
17 | export function useEmailsCount() {
18 | const activeProject = useActiveProject();
19 |
20 | return useSWR(activeProject ? `/projects/id/${activeProject.id}/emails/count` : null);
21 | }
22 |
--------------------------------------------------------------------------------
/packages/dashboard/src/lib/hooks/events.ts:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr';
2 | import {Event} from '@prisma/client';
3 | import {useActiveProject} from './projects';
4 |
5 | /**
6 | *
7 | */
8 | export function useEvents() {
9 | const activeProject = useActiveProject();
10 |
11 | return useSWR<
12 | (Event & {
13 | triggers: {
14 | id: string;
15 | createdAt: Date;
16 | contactId: string;
17 | }[];
18 | })[]
19 | >(activeProject ? `/projects/id/${activeProject.id}/events` : null);
20 | }
21 |
22 | /**
23 | *
24 | */
25 | export function useEventsWithoutTriggers() {
26 | const activeProject = useActiveProject();
27 |
28 | return useSWR(activeProject ? `/projects/id/${activeProject.id}/events?triggers=false` : null);
29 | }
30 |
--------------------------------------------------------------------------------
/packages/dashboard/src/lib/hooks/projects.ts:
--------------------------------------------------------------------------------
1 | import {Action, Contact, Email, Event, Project, Role} from '@prisma/client';
2 | import {useAtom} from 'jotai';
3 | import useSWR from 'swr';
4 | import {atomActiveProject} from '../atoms/project';
5 |
6 | /**
7 | *
8 | */
9 | export function useProjects() {
10 | return useSWR('/users/@me/projects');
11 | }
12 |
13 | /**
14 | *
15 | */
16 | export function useActiveProject(): Project | null {
17 | const [activeProject, setActiveProject] = useAtom(atomActiveProject);
18 | const {data: projects} = useProjects();
19 |
20 | if (!projects) {
21 | return null;
22 | }
23 |
24 | if (activeProject && !projects.find(project => project.id === activeProject)) {
25 | setActiveProject(null);
26 | window.localStorage.removeItem('project');
27 | }
28 |
29 | if (!activeProject && projects.length > 0) {
30 | setActiveProject(projects[0].id);
31 | window.localStorage.setItem('project', projects[0].id);
32 | }
33 |
34 | return projects.find(project => project.id === activeProject) ?? null;
35 | }
36 |
37 | /**
38 | *
39 | */
40 | export function useActiveProjectMemberships() {
41 | const activeProject = useActiveProject();
42 |
43 | return useSWR<
44 | {
45 | userId: string;
46 | email: string;
47 | role: Role;
48 | }[]
49 | >(activeProject ? `/projects/id/${activeProject.id}/memberships` : null);
50 | }
51 |
52 | /**
53 | *
54 | */
55 | export function useActiveProjectFeed(page: number) {
56 | const activeProject = useActiveProject();
57 |
58 | return useSWR<
59 | (
60 | | {
61 | createdAt: Date;
62 | contact: Contact;
63 | event: Event | null;
64 | action: Action | null;
65 | }
66 | | ({
67 | contact: Contact;
68 | } & Email)
69 | )[]
70 | >(activeProject ? `/projects/id/${activeProject.id}/feed?page=${page}` : null);
71 | }
72 |
73 | /**
74 | *
75 | */
76 | export function useActiveProjectVerifiedIdentity() {
77 | const activeProject = useActiveProject();
78 |
79 | return useSWR<{
80 | tokens: string[];
81 | }>(activeProject ? `/identities/id/${activeProject.id}` : null);
82 | }
83 |
--------------------------------------------------------------------------------
/packages/dashboard/src/lib/hooks/templates.ts:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr';
2 | import {Action, Template} from '@prisma/client';
3 | import {useActiveProject} from './projects';
4 |
5 | /**
6 | *
7 | * @param id
8 | */
9 | export function useTemplate(id: string) {
10 | return useSWR(`/v1/templates/${id}`);
11 | }
12 |
13 | /**
14 | *
15 | */
16 | export function useTemplates() {
17 | const activeProject = useActiveProject();
18 |
19 | return useSWR<
20 | (Template & {
21 | actions: Action[];
22 | })[]
23 | >(activeProject ? `/projects/id/${activeProject.id}/templates` : null);
24 | }
25 |
--------------------------------------------------------------------------------
/packages/dashboard/src/lib/hooks/users.ts:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr';
2 |
3 | /**
4 | * Fetch the current user. undefined means loading, null means logged out
5 | *
6 | */
7 | export function useUser() {
8 | return useSWR('/users/@me', {shouldRetryOnError: false});
9 | }
10 |
--------------------------------------------------------------------------------
/packages/dashboard/src/lib/network.ts:
--------------------------------------------------------------------------------
1 | import {API_URI} from './constants';
2 | import {infer as ZodInfer, ZodSchema} from 'zod';
3 |
4 | interface Json {
5 | [x: string]: string | number | boolean | Date | Json | JsonArray;
6 | }
7 |
8 | type JsonArray = (string | number | boolean | Date | Json | JsonArray)[];
9 |
10 | interface TypedSchema extends ZodSchema {
11 | _type: any;
12 | }
13 |
14 | export class network {
15 | /**
16 | * Fetcher function that includes toast support
17 | * @param method Request method
18 | * @param path Request endpoint or path
19 | * @param body Request body
20 | */
21 | public static async fetch(
22 | method: 'GET' | 'PUT' | 'POST' | 'DELETE',
23 | path: string,
24 | body?: Schema extends TypedSchema ? ZodInfer : never,
25 | ): Promise {
26 | const url = path.startsWith('http') ? path : API_URI + path;
27 | const response = await fetch(url, {
28 | method,
29 | body: body && JSON.stringify(body),
30 | headers: body && {'Content-Type': 'application/json'},
31 | credentials: 'include',
32 | });
33 |
34 | const res = await response.json();
35 |
36 | if (response.status >= 400) {
37 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
38 | throw new Error(res?.message ?? 'Something went wrong!');
39 | }
40 |
41 | return res;
42 | }
43 |
44 | public static async mock(
45 | key: string,
46 | method: 'GET' | 'PUT' | 'POST' | 'DELETE',
47 | path: string,
48 | body?: Schema extends TypedSchema ? ZodInfer : never,
49 | ): Promise {
50 | const url = path.startsWith('http') ? path : API_URI + path;
51 | const response = await fetch(url, {
52 | method,
53 | body: body && JSON.stringify(body),
54 | headers: {'Content-Type': 'application/json', 'Authorization': `Bearer ${key}`},
55 | credentials: 'include',
56 | });
57 |
58 | const res = await response.json();
59 |
60 | if (response.status >= 400) {
61 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
62 | throw new Error(res?.message ?? 'Something went wrong!');
63 | }
64 |
65 | return res;
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/packages/dashboard/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import "../../styles/index.css";
2 |
3 | import dayjs from "dayjs";
4 | import utc from "dayjs/plugin/utc";
5 | import { Provider as JotaiProvider } from "jotai";
6 | import type { AppProps } from "next/app";
7 | import Head from "next/head";
8 | import Router, { useRouter } from "next/router";
9 | import NProgress from "nprogress";
10 | import React from "react";
11 | import { Toaster } from "sonner";
12 | import { SWRConfig } from "swr";
13 | import { network } from "../lib/network";
14 | import "nprogress/nprogress.css";
15 | import advancedFormat from "dayjs/plugin/advancedFormat";
16 | import duration from "dayjs/plugin/duration";
17 | import relativeTime from "dayjs/plugin/relativeTime";
18 | import { DefaultSeo } from "next-seo";
19 | import { FullscreenLoader, Redirect } from "../components";
20 | import { NO_AUTH_ROUTES } from "../lib/constants";
21 | import { useUser } from "../lib/hooks/users";
22 |
23 | dayjs.extend(relativeTime);
24 | dayjs.extend(utc);
25 | dayjs.extend(advancedFormat);
26 | dayjs.extend(duration);
27 |
28 | Router.events.on("routeChangeStart", () => NProgress.start());
29 | Router.events.on("routeChangeComplete", () => NProgress.done());
30 | Router.events.on("routeChangeError", () => NProgress.done());
31 |
32 | /**
33 | * Main app component
34 | * @param props Props
35 | * @param props.Component App component
36 | * @param props.pageProps
37 | */
38 | function App({ Component, pageProps }: AppProps) {
39 | const router = useRouter();
40 | const { data: user, error } = useUser();
41 |
42 | if (error && !NO_AUTH_ROUTES.includes(router.route)) {
43 | return ;
44 | }
45 |
46 | if (!user && !NO_AUTH_ROUTES.includes(router.route)) {
47 | return ;
48 | }
49 |
50 | return (
51 | <>
52 |
53 | Plunk Dashboard | The Open-Source Email Platform
54 |
55 |
56 |
57 |
58 |
59 | >
60 | );
61 | }
62 |
63 | /**
64 | * Main app root component that houses all components
65 | * @param props Default nextjs props
66 | */
67 | export default function WithProviders(props: AppProps) {
68 | return (
69 | network.fetch("GET", url),
72 | revalidateOnFocus: true,
73 | }}
74 | >
75 |
76 |
94 |
95 |
96 |
97 |
98 | );
99 | }
100 |
--------------------------------------------------------------------------------
/packages/dashboard/src/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import Document, {Head, Html, Main, NextScript} from 'next/document';
2 | import React from 'react';
3 |
4 | export default class MyDocument extends Document {
5 | public render() {
6 | return (
7 |
8 |
9 | {/* Start fonts */}
10 |
11 |
12 |
16 | {/* End fonts */}
17 |
18 | {/* Start favicon */}
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | {/* End favicon */}
29 |
30 |
31 |
32 |
33 |
34 |
35 | );
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/packages/dashboard/src/pages/analytics/clicks.tsx:
--------------------------------------------------------------------------------
1 | import {useActiveProject} from '../../lib/hooks/projects';
2 | import {useAnalytics} from '../../lib/hooks/analytics';
3 | import {AnalyticsTabs, Card, FullscreenLoader} from '../../components';
4 | import React from 'react';
5 | import {Dashboard} from '../../layouts';
6 | import {Ring} from '@uiball/loaders';
7 | import {Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
8 | import {valueFormatter} from './index';
9 |
10 | /**
11 | *
12 | */
13 | export default function Index() {
14 | const project = useActiveProject();
15 | const {data: analytics} = useAnalytics();
16 |
17 | if (!project) {
18 | return ;
19 | }
20 |
21 | return (
22 | <>
23 |
24 |
25 |
26 |
27 |
28 | {analytics ? (
29 | <>
30 |
31 |
41 |
42 |
43 | >} tickSize={0} width={5} />
44 |
45 | {
51 | return (
52 |
53 |
54 | {payload.value.length > 5 ? `${payload.value.substring(0, 20)}...` : payload.value}
55 |
56 |
57 | );
58 | }}
59 | />
60 |
61 | {
65 | if (active && payload?.length) {
66 | const dataPoint = payload[0];
67 | return (
68 |
69 |
{`${label}`}
70 |
{valueFormatter(dataPoint.value as number)}
71 |
72 | );
73 | }
74 |
75 | return null;
76 | }}
77 | />
78 |
79 |
80 |
81 |
82 | >
83 | ) : (
84 | <>
85 |
86 |
87 |
88 | >
89 | )}
90 |
91 |
92 |
93 | >
94 | );
95 | }
96 |
--------------------------------------------------------------------------------
/packages/dashboard/src/pages/api/health.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next'
2 |
3 | import { network } from './../../lib/network'
4 |
5 | export default async function handler(
6 | req: NextApiRequest,
7 | res: NextApiResponse,
8 | ) {
9 | try {
10 | const timeoutPromise = new Promise((_, reject) => {
11 | setTimeout(() => {
12 | reject(new Error('Timeout'));
13 | }, 2000);
14 | });
15 |
16 | const healthPromise = network.fetch("GET", "/health");
17 |
18 | await Promise.race([healthPromise, timeoutPromise]);
19 |
20 | return res.status(200).json({ message: 'OK' });
21 | } catch (error) {
22 | return res.status(500).json({ message: 'Internal Server Error' });
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/packages/dashboard/src/pages/auth/logout.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from "next/router";
2 | import { useEffect } from "react";
3 | import { FullscreenLoader } from "../../components/";
4 | import { useUser } from "../../lib/hooks/users";
5 | import { network } from "../../lib/network";
6 |
7 | /**
8 | *
9 | */
10 | export default function Index() {
11 | const router = useRouter();
12 |
13 | const { error, mutate } = useUser();
14 |
15 | if (error) {
16 | void router.push("/");
17 | }
18 |
19 | useEffect(() => {
20 | void network.fetch("GET", "/auth/logout").then(async (success) => {
21 | if (success) {
22 | await mutate(null);
23 | await router.push("/");
24 | }
25 | });
26 | }, [mutate, router.push]);
27 |
28 | return ;
29 | }
30 |
--------------------------------------------------------------------------------
/packages/dashboard/src/pages/auth/reset.tsx:
--------------------------------------------------------------------------------
1 | import { zodResolver } from "@hookform/resolvers/zod";
2 | import { UserSchemas, UtilitySchemas } from "@plunk/shared";
3 | import { AnimatePresence, motion } from "framer-motion";
4 | import { useRouter } from "next/router";
5 | import React, { useState } from "react";
6 | import { useForm } from "react-hook-form";
7 | import { Redirect } from "../../components";
8 | import { network } from "../../lib/network";
9 |
10 | interface ResetValues {
11 | password: string;
12 | }
13 |
14 | /**
15 | *
16 | */
17 | export default function Index() {
18 | const router = useRouter();
19 |
20 | if (!router.query.id) {
21 | return ;
22 | }
23 |
24 | const [submitted, setSubmitted] = useState(false);
25 | const [hidePassword, setHidePassword] = useState(true);
26 |
27 | const {
28 | register,
29 | handleSubmit,
30 | formState: { errors },
31 | } = useForm({
32 | resolver: zodResolver(UserSchemas.credentials.pick({ password: true })),
33 | });
34 |
35 | const resetPassword = async (data: ResetValues) => {
36 | const schema = UtilitySchemas.id.merge(UserSchemas.credentials.pick({ password: true }));
37 |
38 | setSubmitted(true);
39 | await network.fetch<
40 | {
41 | success: true;
42 | },
43 | typeof schema
44 | >("POST", "/auth/reset", {
45 | id: router.query.id as string,
46 | ...data,
47 | });
48 |
49 | return router.push("/auth/login");
50 | };
51 |
52 | return (
53 |
54 |
55 |
70 |
71 |
Reset password
72 |
Please enter your new password and confirm it.
73 |
74 |
163 |
164 |
165 | );
166 | }
167 |
--------------------------------------------------------------------------------
/packages/dashboard/src/pages/manage/[id].tsx:
--------------------------------------------------------------------------------
1 | import type { UtilitySchemas } from "@plunk/shared";
2 | import type { User } from "@prisma/client";
3 | import { motion } from "framer-motion";
4 | import { NextSeo } from "next-seo";
5 | import { useRouter } from "next/router";
6 | import React, { useState } from "react";
7 | import { toast } from "sonner";
8 | import { FullscreenLoader, Redirect } from "../../components";
9 | import { useContact } from "../../lib/hooks/contacts";
10 | import { network } from "../../lib/network";
11 |
12 | /**
13 | *
14 | */
15 | export default function Index() {
16 | const router = useRouter();
17 |
18 | if (!router.isReady) {
19 | return ;
20 | }
21 |
22 | const { data: contact, error, mutate } = useContact({ id: router.query.id as string, withProject: true });
23 | const [submitted, setSubmitted] = useState(false);
24 |
25 | if (error) {
26 | return ;
27 | }
28 |
29 | if (!contact) {
30 | return ;
31 | }
32 |
33 | const update = () => {
34 | setSubmitted(true);
35 |
36 | toast.promise(
37 | network.mock(
38 | contact.project.public,
39 | "POST",
40 | `/v1/contacts/${contact.subscribed ? "unsubscribe" : "subscribe"}`,
41 | {
42 | id: contact.id,
43 | },
44 | ),
45 | {
46 | loading: "Updating your preferences",
47 | success: () => {
48 | void mutate();
49 | return "Updated your preferences";
50 | },
51 | error: "Could not update your preferences!",
52 | },
53 | );
54 |
55 | setSubmitted(false);
56 | };
57 |
58 | return (
59 | <>
60 |
72 |
73 |
74 |
75 | {contact.subscribed ? "Unsubscribe from" : "Subscribe to"} {contact.project.name}
76 |
77 |
78 | {contact.subscribed
79 | ? `You will no longer receive emails from ${contact.project.name} on ${contact.email} when you confirm that you want to unsubscribe.`
80 | : `By confirming your subscription to ${contact.project.name} for ${contact.email} you agree to receive emails from us.`}
81 |
82 |
83 |
91 | {submitted ? (
92 |
105 | ) : (
106 | `${contact.subscribed ? "Unsubscribe" : "Subscribe"}`
107 | )}
108 |
109 |
110 |
111 |
112 | >
113 | );
114 | }
115 |
--------------------------------------------------------------------------------
/packages/dashboard/src/pages/onboarding/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {motion} from 'framer-motion';
3 | import {useRouter} from 'next/router';
4 | import {TerminalSquare, Workflow} from 'lucide-react';
5 |
6 | /**
7 | *
8 | */
9 | export default function Index() {
10 | const router = useRouter();
11 |
12 | return (
13 | <>
14 |
15 |
16 |
Pick your fighter
17 |
18 |
19 | Don't worry! You can use both, but we recommend starting with one.
20 |
21 |
22 |
23 |
28 |
29 |
30 |
31 |
32 |
33 |
Actions
34 |
Repeatable workflows that are triggered by your app
35 |
36 |
37 |
router.push('/onboarding/actions')}
39 | whileHover={{scale: 1.05}}
40 | whileTap={{scale: 0.9}}
41 | className={
42 | 'flex items-center gap-x-0.5 rounded-md bg-neutral-800 px-10 py-2.5 text-center text-sm font-medium text-white sm:col-span-2'
43 | }
44 | >
45 | Start with actions
46 |
47 |
48 |
53 |
54 |
55 |
56 |
57 |
58 |
Transactional
59 |
Emails sent with a single API call
60 |
61 |
router.push('/onboarding/transactional')}
63 | whileHover={{scale: 1.05}}
64 | whileTap={{scale: 0.9}}
65 | className={
66 | 'mx-auto flex items-center gap-x-0.5 rounded-md bg-neutral-800 px-10 py-2.5 text-center text-sm font-medium text-white sm:col-span-2'
67 | }
68 | >
69 | Start with transactional
70 |
71 |
72 |
73 |
74 | >
75 | );
76 | }
77 |
--------------------------------------------------------------------------------
/packages/dashboard/src/pages/settings/account.tsx:
--------------------------------------------------------------------------------
1 | import {Dashboard} from '../../layouts';
2 | import {Card, FullscreenLoader} from '../../components';
3 | import {useUser} from '../../lib/hooks/users';
4 | import React from 'react';
5 |
6 | /**
7 | *
8 | */
9 | export default function Index() {
10 | const {data: user} = useUser();
11 |
12 | if (!user) {
13 | return ;
14 | }
15 |
16 | return (
17 | <>
18 |
19 |
20 |
21 |
22 |
25 |
36 |
37 |
38 |
39 |
40 | >
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/packages/dashboard/src/pages/settings/api.tsx:
--------------------------------------------------------------------------------
1 | import type { Project } from "@prisma/client";
2 | import React, { useState } from "react";
3 | import { Card, FullscreenLoader, Modal, SettingTabs } from "../../components";
4 | import { Dashboard } from "../../layouts";
5 | import { useActiveProject, useProjects } from "../../lib/hooks/projects";
6 | import { network } from "../../lib/network";
7 |
8 | import { RefreshCw } from "lucide-react";
9 | import { toast } from "sonner";
10 |
11 | /**
12 | *
13 | */
14 | export default function Index() {
15 | const [showRegenerateModal, setShowRegenerateModal] = useState(false);
16 | const [project, setProject] = useState();
17 |
18 | const activeProject = useActiveProject();
19 | const { data: projects, mutate: projectMutate } = useProjects();
20 |
21 | if (activeProject && !project) {
22 | setProject(activeProject);
23 | }
24 |
25 | if (!project || !projects) {
26 | return ;
27 | }
28 |
29 | if (!activeProject) {
30 | return ;
31 | }
32 |
33 | const regenerate = () => {
34 | setShowRegenerateModal(!showRegenerateModal);
35 |
36 | toast.promise(
37 | network
38 | .fetch<{
39 | success: true;
40 | project: Project;
41 | }>("POST", `/projects/id/${project.id}/regenerate`)
42 | .then(async (res) => {
43 | await projectMutate(
44 | [
45 | ...projects.filter((project) => {
46 | return project.id !== res.project.id;
47 | }),
48 | res.project,
49 | ],
50 | false,
51 | );
52 | }),
53 | {
54 | loading: "Regenerating API keys...",
55 | success: "Successfully regenerated API keys!",
56 | error: "Failed to create new API keys",
57 | },
58 | );
59 | };
60 |
61 | return (
62 | <>
63 | setShowRegenerateModal(!showRegenerateModal)}
66 | onAction={regenerate}
67 | type={"danger"}
68 | title={"Are you sure?"}
69 | description={"Any applications that use your previously generated keys will stop working!"}
70 | />
71 |
72 |
73 |
78 |
87 | >
88 | }
89 | >
90 | {
92 | void navigator.clipboard.writeText(activeProject.public);
93 | toast.success("Copied your public API key");
94 | }}
95 | >
96 |
97 |
98 | {activeProject.public}
99 |
100 |
101 |
102 | Use this key for any front-end services. This key can only be used to publish events.
103 |
104 |
105 |
106 |
107 |
{
109 | void navigator.clipboard.writeText(activeProject.secret);
110 | toast.success("Copied your secret API key");
111 | }}
112 | >
113 |
114 |
115 | {activeProject.secret}
116 |
117 |
118 |
119 | Use this key for any secure back-end services. This key gives complete access to your Plunk setup.
120 |
121 |
122 |
123 |
124 |
125 | >
126 | );
127 | }
128 |
--------------------------------------------------------------------------------
/packages/dashboard/src/pages/settings/index.tsx:
--------------------------------------------------------------------------------
1 | import { Redirect } from "../../components";
2 |
3 | /**
4 | *
5 | */
6 | export default function Index() {
7 | return ;
8 | }
9 |
--------------------------------------------------------------------------------
/packages/dashboard/src/pages/settings/project.tsx:
--------------------------------------------------------------------------------
1 | import { zodResolver } from "@hookform/resolvers/zod";
2 | import { ProjectSchemas, type UtilitySchemas } from "@plunk/shared";
3 | import { network } from "dashboard/src/lib/network";
4 | import { motion } from "framer-motion";
5 | import { useRouter } from "next/router";
6 | import React, { useEffect, useState } from "react";
7 | import { useForm } from "react-hook-form";
8 | import { toast } from "sonner";
9 | import { Card, FullscreenLoader, Input, Modal, SettingTabs } from "../../components";
10 | import { Dashboard } from "../../layouts";
11 | import { useActiveProject, useActiveProjectMemberships, useProjects } from "../../lib/hooks/projects";
12 | import { useUser } from "../../lib/hooks/users";
13 |
14 | interface ProjectValues {
15 | name: string;
16 | url: string;
17 | }
18 |
19 | /**
20 | *
21 | */
22 | export default function Index() {
23 | const router = useRouter();
24 | const activeProject = useActiveProject();
25 | const { data: user } = useUser();
26 | const { data: memberships } = useActiveProjectMemberships();
27 | const { mutate: projectsMutate } = useProjects();
28 |
29 | const {
30 | register,
31 | handleSubmit,
32 | formState: { errors },
33 | reset,
34 | } = useForm({
35 | resolver: zodResolver(ProjectSchemas.update.omit({ id: true })),
36 | });
37 |
38 | const [showDeleteModal, setShowDeleteModal] = useState(false);
39 |
40 | useEffect(() => {
41 | if (!activeProject) {
42 | return;
43 | }
44 |
45 | reset(activeProject);
46 | }, [reset, activeProject]);
47 |
48 | if (!activeProject || !memberships || !user) {
49 | return ;
50 | }
51 |
52 | const update = async (data: ProjectValues) => {
53 | toast.promise(
54 | network.fetch<
55 | {
56 | success: true;
57 | },
58 | typeof ProjectSchemas.update
59 | >("PUT", "/projects/update/", {
60 | id: activeProject.id,
61 | ...data,
62 | }),
63 | {
64 | loading: "Updating your project",
65 | success: "Updated your project",
66 | error: "Could not update your project",
67 | },
68 | );
69 |
70 | await projectsMutate();
71 | };
72 |
73 | const deleteProject = async () => {
74 | setShowDeleteModal(!showDeleteModal);
75 |
76 | await fetch("/api/plunk", {
77 | method: "POST",
78 | body: JSON.stringify({
79 | event: "project-deleted",
80 | email: user.email,
81 | data: {
82 | project: activeProject.name,
83 | },
84 | }),
85 | headers: { "Content-Type": "application/json" },
86 | });
87 |
88 | toast.promise(
89 | network
90 | .fetch<
91 | {
92 | success: true;
93 | },
94 | typeof UtilitySchemas.id
95 | >("DELETE", "/projects/delete", {
96 | id: activeProject.id,
97 | })
98 | .then(async () => {
99 | localStorage.removeItem("project");
100 | await router.push("/");
101 | window.location.reload();
102 | }),
103 | {
104 | loading: "Deleting your project",
105 | success: "Deleted your project",
106 | error: "Could not delete your project",
107 | },
108 | );
109 | };
110 |
111 | return (
112 | <>
113 | setShowDeleteModal(!showDeleteModal)}
116 | onAction={deleteProject}
117 | type={"danger"}
118 | title={"Are you sure?"}
119 | description={
120 | "All data associated with this project will also be permanently deleted. This action cannot be reversed!"
121 | }
122 | />
123 |
124 |
125 |
126 |
141 |
142 | {memberships.find((membership) => membership.userId === user.id)?.role === "OWNER" ? (
143 |
144 |
145 |
146 |
Delete your project
147 |
148 | Deleting your project may have unwanted consequences. All data associated with this project will get deleted
149 | and can not be recovered!{" "}
150 |
151 |
152 |
160 |
161 |
162 | ) : null}
163 |
164 | >
165 | );
166 | }
167 |
--------------------------------------------------------------------------------
/packages/dashboard/src/pages/subscribe/[id].tsx:
--------------------------------------------------------------------------------
1 | import type { UtilitySchemas } from "@plunk/shared";
2 | import type { User } from "@prisma/client";
3 | import { motion } from "framer-motion";
4 | import { NextSeo } from "next-seo";
5 | import { useRouter } from "next/router";
6 | import React, { useState } from "react";
7 | import { toast } from "sonner";
8 | import { FullscreenLoader, Redirect } from "../../components";
9 | import { useContact } from "../../lib/hooks/contacts";
10 | import { network } from "../../lib/network";
11 |
12 | /**
13 | *
14 | */
15 | export default function Index() {
16 | const router = useRouter();
17 |
18 | if (!router.isReady) {
19 | return ;
20 | }
21 |
22 | const { data: contact, error } = useContact({
23 | id: router.query.id as string,
24 | withProject: true,
25 | });
26 | const [submitted, setSubmitted] = useState<"initial" | "loading" | "submitted">("initial");
27 |
28 | if (error) {
29 | return ;
30 | }
31 |
32 | if (!contact) {
33 | return ;
34 | }
35 |
36 | const subscribe = async () => {
37 | setSubmitted("loading");
38 |
39 | toast.promise(
40 | network.mock(contact.project.public, "POST", "/v1/contacts/subscribe", {
41 | id: contact.id,
42 | }),
43 | {
44 | loading: "Subscribing...",
45 | success: "Thank you for subscribing!",
46 | error: "Could not subscribe you!",
47 | },
48 | );
49 |
50 | setSubmitted("submitted");
51 | };
52 |
53 | return (
54 | <>
55 |
67 |
68 |
69 | {submitted === "submitted" ? (
70 | <>
71 |
72 |
87 |
97 |
98 |
99 |
100 |
You have been subscribed!
101 | >
102 | ) : (
103 | <>
104 |
Confirm your subscription?
105 |
106 | By confirming your subscription to {contact.project.name} for {contact.email} you agree to receive emails from
107 | us.
108 |
109 |
110 |
118 | {submitted === "loading" ? (
119 |
132 | ) : (
133 | "Subscribe"
134 | )}
135 |
136 |
137 | >
138 | )}
139 |
140 |
141 | >
142 | );
143 | }
144 |
--------------------------------------------------------------------------------
/packages/dashboard/src/pages/templates/index.tsx:
--------------------------------------------------------------------------------
1 | import dayjs from "dayjs";
2 | import { motion } from "framer-motion";
3 | import { LayoutTemplate, Plus } from "lucide-react";
4 | import Link from "next/link";
5 | import React from "react";
6 | import { Alert, Badge, Card, Empty, Skeleton } from "../../components";
7 | import { Dashboard } from "../../layouts";
8 | import { useTemplates } from "../../lib/hooks/templates";
9 |
10 | /**
11 | *
12 | */
13 | export default function Index() {
14 | const { data: templates } = useTemplates();
15 |
16 | return (
17 | <>
18 |
19 | {templates?.length === 0 && (
20 |
21 |
22 |
23 | Want us to help you get started? We can help you build your first action in less than 5 minutes.
24 |
25 |
26 |
32 | Build an action
33 |
34 |
35 |
36 | )}
37 |
38 |
43 |
44 |
51 |
52 | New
53 |
54 |
55 | >
56 | }
57 | >
58 | {templates ? (
59 | templates.length > 0 ? (
60 | <>
61 |
62 | {templates
63 | .sort((a, b) => {
64 | if (a.actions.length > 0 && b.actions.length === 0) {
65 | return -1;
66 | }
67 | if (a.actions.length === 0 && b.actions.length > 0) {
68 | return 1;
69 | }
70 | if (a.subject < b.subject) {
71 | return -1;
72 | }
73 | if (a.subject > b.subject) {
74 | return 1;
75 | }
76 | return 0;
77 | })
78 | .map((t) => {
79 | return (
80 | <>
81 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
{t.subject}
92 | {t.actions.length > 0 && Active}
93 |
94 |
Last edited {dayjs().to(t.updatedAt)}
95 |
96 |
97 |
98 |
99 |
100 |
104 |
120 |
121 |
Edit
122 |
123 |
124 |
125 |
126 |
127 | >
128 | );
129 | })}
130 |
131 | >
132 | ) : (
133 | <>
134 |
135 | >
136 | )
137 | ) : (
138 |
139 | )}
140 |
141 |
142 | >
143 | );
144 | }
145 |
--------------------------------------------------------------------------------
/packages/dashboard/src/pages/unsubscribe/[id].tsx:
--------------------------------------------------------------------------------
1 | import type { UtilitySchemas } from "@plunk/shared";
2 | import type { User } from "@prisma/client";
3 | import { motion } from "framer-motion";
4 | import { NextSeo } from "next-seo";
5 | import { useRouter } from "next/router";
6 | import React, { useState } from "react";
7 | import { toast } from "sonner";
8 | import { FullscreenLoader, Redirect } from "../../components";
9 | import { useContact } from "../../lib/hooks/contacts";
10 | import { network } from "../../lib/network";
11 |
12 | /**
13 | *
14 | */
15 | export default function Index() {
16 | const router = useRouter();
17 |
18 | if (!router.isReady) {
19 | return ;
20 | }
21 |
22 | const { data: contact, error } = useContact({
23 | id: router.query.id as string,
24 | withProject: true,
25 | });
26 | const [submitted, setSubmitted] = useState<"initial" | "loading" | "submitted">("initial");
27 |
28 | if (error) {
29 | return ;
30 | }
31 |
32 | if (!contact) {
33 | return ;
34 | }
35 |
36 | const unsubscribe = () => {
37 | setSubmitted("loading");
38 |
39 | toast.promise(
40 | network.mock(contact.project.public, "POST", "/v1/contacts/unsubscribe", {
41 | id: contact.id,
42 | }),
43 | {
44 | loading: "Unsubscribing",
45 | success: "Unsubscribed",
46 | error: "Could not unsubscribe you!",
47 | },
48 | );
49 |
50 | setSubmitted("submitted");
51 | };
52 |
53 | return (
54 | <>
55 |
67 |
68 |
69 | {submitted === "submitted" ? (
70 | <>
71 |
72 |
87 |
97 |
98 |
99 |
100 |
You have been unsubscribed!
101 | >
102 | ) : (
103 | <>
104 |
105 | Are you sure you want to unsubscribe?
106 |
107 |
108 | You will no longer receive emails from {contact.project.name} on the email {contact.email} when you confirm that
109 | you want to unsubscribe.
110 |
111 |
112 |
120 | {submitted === "loading" ? (
121 |
134 | ) : (
135 | "Unsubscribe"
136 | )}
137 |
138 |
139 | >
140 | )}
141 |
142 |
143 | >
144 | );
145 | }
146 |
--------------------------------------------------------------------------------
/packages/dashboard/styles/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html {
6 | scroll-behavior: smooth;
7 | -webkit-overflow-scrolling: touch;
8 | }
9 |
10 | #nprogress .bar {
11 | background: #171717 !important;
12 | }
13 |
14 | .ProseMirror p.is-editor-empty:first-child::before {
15 | @apply text-neutral-400;
16 | content: attr(data-placeholder);
17 | float: left;
18 | height: 0;
19 | pointer-events: none;
20 | }
21 |
22 | .ProseMirror progress {
23 | @apply rounded-xl w-full h-8
24 | }
25 |
26 | .ProseMirror progress::-webkit-progress-bar {
27 | @apply bg-blue-100 rounded-xl
28 | }
29 |
30 | .ProseMirror progress::-webkit-progress-value {
31 | @apply bg-blue-500 rounded-xl
32 | }
33 |
34 | .tippy-box[data-theme~="custom"] {
35 | @apply border border-neutral-300 bg-neutral-50 text-neutral-800 shadow-xl
36 | }
37 |
38 | circle {
39 | animation: moveCircle 10s linear infinite;
40 | }
41 |
42 | @keyframes moveCircle {
43 | from {
44 | stroke-dashoffset: 100%;
45 | }
46 |
47 | to {
48 | stroke-dashoffset: 0%;
49 | }
50 | }
51 |
52 | .revert-tailwind {
53 | all: initial;
54 | }
55 |
56 | .revert-tailwind > * {
57 | all: revert;
58 | }
59 |
--------------------------------------------------------------------------------
/packages/dashboard/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const defaultTheme = require('tailwindcss/defaultTheme');
2 |
3 | module.exports = {
4 | content: ['./src/**/*.{ts,tsx}'],
5 | theme: {
6 | fontFamily: {
7 | sans: ['"Inter Var"', 'Inter', ...defaultTheme.fontFamily.sans],
8 | mono: ['"Jetbrains Mono"', ...defaultTheme.fontFamily.mono],
9 | },
10 | },
11 | plugins: [
12 | require('@tailwindcss/forms'),
13 | require('@tailwindcss/aspect-ratio'),
14 | require('@tailwindcss/typography'),
15 | require('tailwind-scrollbar')({nocompatible: true}),
16 | ],
17 | };
18 |
--------------------------------------------------------------------------------
/packages/dashboard/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "noEmit": true,
10 | "jsx": "preserve",
11 | "incremental": true
12 | },
13 | "exclude": [
14 | "node_modules",
15 | "dist",
16 | "build",
17 | ".next"
18 | ],
19 | "include": [
20 | "src",
21 | "next-env.d.ts",
22 | "custom.d.ts"
23 | ]
24 | }
25 |
--------------------------------------------------------------------------------
/packages/shared/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@plunk/shared",
3 | "version": "1.0.0",
4 | "main": "dist/index.js",
5 | "types": "dist/index.d.ts",
6 | "private": true,
7 | "dependencies": {
8 | "dayjs": "^1.11.12",
9 | "zod": "^3.23.8"
10 | },
11 | "devDependencies": {
12 | "typescript": "^5.5.3"
13 | },
14 | "scripts": {
15 | "watch": "tsc && tsc -w",
16 | "build": "tsc",
17 | "dev": "yarn watch",
18 | "clean": "rimraf node_modules dist .turbo"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/packages/shared/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "rootDir": "src",
5 | "outDir": "dist",
6 | "declaration": true,
7 | "target": "ES2020",
8 | "lib": ["ES2020", "DOM"],
9 | "esModuleInterop": true,
10 | "moduleResolution": "node"
11 | },
12 | "exclude": ["dist", "node_modules"]
13 | }
14 |
--------------------------------------------------------------------------------
/prisma/.env.example:
--------------------------------------------------------------------------------
1 | DATABASE_URL=postgresql://postgres:postgres@localhost:55432/postgres
--------------------------------------------------------------------------------
/prisma/migrations/20240924145046_template_sender_overwrite/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "campaigns" ADD COLUMN "email" TEXT,
3 | ADD COLUMN "from" TEXT;
4 |
5 | -- AlterTable
6 | ALTER TABLE "templates" ADD COLUMN "email" TEXT,
7 | ADD COLUMN "from" TEXT;
8 |
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "postgresql"
--------------------------------------------------------------------------------
/tools/preinstall.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Forces use of yarn over npm
3 | *
4 | * @description Do NOT allow using `npm` as package manager.
5 | */
6 | if (!process.env.npm_execpath.includes('yarn')) {
7 | console.error('You must use Yarn to install dependencies:');
8 | console.error('$ yarn install\n');
9 | process.exit(1);
10 | }
11 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2020",
4 | "allowJs": false,
5 | "skipLibCheck": true,
6 | "esModuleInterop": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noFallthroughCasesInSwitch": true,
10 | "module": "CommonJS",
11 | "moduleResolution": "node",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "react-jsx",
15 | "downlevelIteration": true,
16 | "emitDecoratorMetadata": true,
17 | "declaration": true,
18 | "experimentalDecorators": true,
19 | "baseUrl": "packages",
20 | "paths": {
21 | "@plunk/shared": [
22 | "./packages/shared/"
23 | ],
24 | "@plunk/shared/*": [
25 | "./packages/shared/*"
26 | ]
27 | }
28 | },
29 | "exclude": [
30 | "node_modules"
31 | ]
32 | }
--------------------------------------------------------------------------------