├── .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 | ![card.png](/assets/card.png) 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 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 72 | 73 | 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 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
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 | 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 | 49 | ); 50 | } 51 | 52 | if (typeof value[1] === 'boolean') { 53 | return ( 54 | 94 | ); 95 | } 96 | 97 | // @ts-ignore 98 | return ; 99 | })} 100 | 101 | ); 102 | })} 103 | 104 |
35 | {header} 36 |
Not specified 55 | {value[1] ? ( 56 | 61 | 68 | 69 | ) : ( 70 | 77 | 84 | 91 | 92 | )} 93 | {value[1]}
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 | 32 | {icon} 33 | 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 |
56 | 57 | 64 | 68 | 69 |
70 |
71 |

Reset password

72 |

Please enter your new password and confirm it.

73 |
74 |
75 |
76 | 79 |
80 | 89 |
90 | setHidePassword(!hidePassword)} 92 | className="h-5 w-5 text-neutral-400" 93 | xmlns="http://www.w3.org/2000/svg" 94 | viewBox="0 0 20 20" 95 | fill="currentColor" 96 | aria-hidden="true" 97 | > 98 | {hidePassword ? ( 99 | <> 100 | 105 | 106 | 107 | ) : ( 108 | <> 109 | 110 | 115 | 116 | )} 117 | 118 |
119 |
120 | 121 | {errors.password?.message && ( 122 | 128 | Password must be atleast 6 characters long 129 | 130 | )} 131 | 132 |
133 | 134 |
135 | 143 | {submitted ? ( 144 | 150 | 151 | 156 | 157 | ) : ( 158 | "Change password" 159 | )} 160 | 161 |
162 |
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 | 98 | 99 | 104 | 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 |
127 |
128 | 129 | 130 |
131 | 138 | Save 139 | 140 |
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 | 125 | 126 | 131 | 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 | 105 | 112 | 119 | 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 | 127 | 128 | 133 | 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 | } --------------------------------------------------------------------------------