diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 3104c74..9d3c734 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -github: [driaug] +github: [ driaug ] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 70065e9..776500f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,7 @@ Support can be asked in the `#contributions` channel of the [Plunk Discord serve - Copy the `.env.example` files in the `api`, `dashboard` and `prisma` folder to `.env` in their respective folders. ### 3. Start resources + - Run `yarn services:up` to start a local database and a local redis server. - Run `yarn migrate` to apply the migrations to the database. - Run `yarn build:shared` to build the shared package. diff --git a/README.md b/README.md index 6212bba..ab791da 100644 --- a/README.md +++ b/README.md @@ -13,16 +13,30 @@

## Introduction -Plunk is an open-source email platform built on top of AWS SES. It allows you to easily send emails from your applications. -It can be considered as a self-hosted alternative to services like [SendGrid](https://sendgrid.com/), [Resend](https://resend.com) or [Mailgun](https://www.mailgun.com/). + +Plunk is an open-source email platform built on top of AWS SES. It allows you to easily send emails from your +applications. +It can be considered as a self-hosted alternative to services +like [SendGrid](https://sendgrid.com/), [Resend](https://resend.com) or [Mailgun](https://www.mailgun.com/). ## Features + - **Transactional Emails**: Send emails straight from your API - **Automations**: Create automations based on user actions - **Broadcasts**: Send newsletters and product updates to big audiences ## Self-hosting Plunk + The easiest way to self-host Plunk is by using the `driaug/plunk` Docker image. You can pull the latest image from [Docker Hub](https://hub.docker.com/r/driaug/plunk/). -A complete guide on how to deploy Plunk can be found in the [documentation](https://docs.useplunk.com/getting-started/self-hosting). +A complete guide on how to deploy Plunk can be found in +the [documentation](https://docs.useplunk.com/getting-started/self-hosting). + +## Contributing + +You are welcome to contribute to Plunk. You can find a guide on how to contribute in [CONTRIBUTING.md](CONTRIBUTING.md). + + + + \ No newline at end of file diff --git a/biome.json b/biome.json index 0a914e0..c280146 100644 --- a/biome.json +++ b/biome.json @@ -17,5 +17,10 @@ "noStaticOnlyClass": "off" } } + }, + "formatter": { + "indentStyle": "tab", + "indentWidth": 1, + "lineWidth": 120 } } diff --git a/packages/api/.env.example b/packages/api/.env.example index 51dc9eb..c56c733 100644 --- a/packages/api/.env.example +++ b/packages/api/.env.example @@ -1,6 +1,6 @@ # ENV JWT_SECRET=mysupersecretJWTsecret -REDIS_URL=redis://127.0.0.1:6379 +REDIS_URL=redis://127.0.0.1:56379 DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres # AWS diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index e53a767..e28e86e 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -82,22 +82,20 @@ server.app.use((req, res, next) => { next(); }); -server.app.use( - (error: Error, req: Request, res: Response, _next: NextFunction) => { - const code = error instanceof HttpException ? error.code : 500; +server.app.use((error: Error, req: Request, res: Response, _next: NextFunction) => { + const code = error instanceof HttpException ? error.code : 500; - if (NODE_ENV !== "development") { - signale.error(error); - } + if (NODE_ENV !== "development") { + signale.error(error); + } - res.status(code).json({ - code, - error: STATUS_CODES[code], - message: error.message, - time: Date.now(), - }); - }, -); + res.status(code).json({ + code, + error: STATUS_CODES[code], + message: error.message, + time: Date.now(), + }); +}); void prisma.$connect().then(() => { server.app.listen(4000, () => { diff --git a/packages/api/src/app/constants.ts b/packages/api/src/app/constants.ts index 0a9660a..bca1148 100644 --- a/packages/api/src/app/constants.ts +++ b/packages/api/src/app/constants.ts @@ -3,10 +3,7 @@ * @param key The key * @param defaultValue An optional default value if the environment variable does not exist */ -export function validateEnv( - key: keyof NodeJS.ProcessEnv, - defaultValue?: T, -): T { +export function validateEnv(key: keyof NodeJS.ProcessEnv, defaultValue?: T): T { const value = process.env[key] as T | undefined; if (!value) { @@ -21,10 +18,7 @@ export function validateEnv( // ENV export const JWT_SECRET = validateEnv("JWT_SECRET"); -export const NODE_ENV = validateEnv<"development" | "production">( - "NODE_ENV", - "production", -); +export const NODE_ENV = validateEnv<"development" | "production">("NODE_ENV", "production"); export const REDIS_URL = validateEnv("REDIS_URL"); @@ -36,6 +30,4 @@ export const APP_URI = validateEnv("APP_URI", "http://localhost:3000"); export const AWS_REGION = validateEnv("AWS_REGION"); export const AWS_ACCESS_KEY_ID = validateEnv("AWS_ACCESS_KEY_ID"); export const AWS_SECRET_ACCESS_KEY = validateEnv("AWS_SECRET_ACCESS_KEY"); -export const AWS_SES_CONFIGURATION_SET = validateEnv( - "AWS_SES_CONFIGURATION_SET", -); +export const AWS_SES_CONFIGURATION_SET = validateEnv("AWS_SES_CONFIGURATION_SET"); diff --git a/packages/api/src/app/cron.ts b/packages/api/src/app/cron.ts index 9ba73a5..54ac4a9 100644 --- a/packages/api/src/app/cron.ts +++ b/packages/api/src/app/cron.ts @@ -1,15 +1,15 @@ -import cron from 'node-cron'; -import {API_URI} from './constants'; -import signale from 'signale'; +import cron from "node-cron"; +import signale from "signale"; +import { API_URI } from "./constants"; -export const task = cron.schedule('* * * * *', () => { - signale.info('Running scheduled tasks'); - void fetch(`${API_URI}/tasks`, { - method: 'POST', - }); +export const task = cron.schedule("* * * * *", () => { + signale.info("Running scheduled tasks"); + void fetch(`${API_URI}/tasks`, { + method: "POST", + }); - signale.info('Updating verified identities'); - void fetch(`${API_URI}/identities/update`, { - method: 'POST', - }); + signale.info("Updating verified identities"); + void fetch(`${API_URI}/identities/update`, { + method: "POST", + }); }); diff --git a/packages/api/src/controllers/Auth.ts b/packages/api/src/controllers/Auth.ts index a7048c2..6a3b81b 100644 --- a/packages/api/src/controllers/Auth.ts +++ b/packages/api/src/controllers/Auth.ts @@ -35,12 +35,7 @@ export class Auth { return res.json({ success: false, data: "Incorrect email or password" }); } - await redis.set( - Keys.User.id(user.id), - JSON.stringify(user), - "EX", - REDIS_ONE_MINUTE * 60, - ); + await redis.set(Keys.User.id(user.id), JSON.stringify(user), "EX", REDIS_ONE_MINUTE * 60); const token = jwt.sign(user.id); const cookie = UserService.cookieOptions(); @@ -70,12 +65,7 @@ export class Auth { }, }); - await redis.set( - Keys.User.id(created_user.id), - JSON.stringify(created_user), - "EX", - REDIS_ONE_MINUTE * 60, - ); + await redis.set(Keys.User.id(created_user.id), JSON.stringify(created_user), "EX", REDIS_ONE_MINUTE * 60); const token = jwt.sign(created_user.id); const cookie = UserService.cookieOptions(); @@ -88,9 +78,7 @@ export class Auth { @Post("reset") public async reset(req: Request, res: Response) { - const { id, password } = UtilitySchemas.id - .merge(UserSchemas.credentials.pick({ password: true })) - .parse(req.body); + const { id, password } = UtilitySchemas.id.merge(UserSchemas.credentials.pick({ password: true })).parse(req.body); const user = await UserService.id(id); @@ -115,11 +103,7 @@ export class Auth { @Get("logout") public logout(req: Request, res: Response) { - res.cookie( - UserService.COOKIE_NAME, - "", - UserService.cookieOptions(new Date()), - ); + res.cookie(UserService.COOKIE_NAME, "", UserService.cookieOptions(new Date())); return res.json(true); } } diff --git a/packages/api/src/controllers/Identities.ts b/packages/api/src/controllers/Identities.ts index c5c64a6..eb1f08e 100644 --- a/packages/api/src/controllers/Identities.ts +++ b/packages/api/src/controllers/Identities.ts @@ -8,12 +8,7 @@ import { type IJwt, isAuthenticated } from "../middleware/auth"; import { ProjectService } from "../services/ProjectService"; import { Keys } from "../services/keys"; import { redis } from "../services/redis"; -import { - getIdentities, - getIdentityVerificationAttributes, - ses, - verifyIdentity, -} from "../util/ses"; +import { getIdentities, getIdentityVerificationAttributes, ses, verifyIdentity } from "../util/ses"; @Controller("identities") export class Identities { @@ -117,14 +112,10 @@ export class Identities { take: 99, }); - const awsIdentities = await getIdentities( - dbIdentities.map((i) => i.email as string), - ); + const awsIdentities = await getIdentities(dbIdentities.map((i) => i.email as string)); for (const identity of awsIdentities) { - const projectId = dbIdentities.find((i) => - i.email?.endsWith(identity.email), - ); + const projectId = dbIdentities.find((i) => i.email?.endsWith(identity.email)); const project = await ProjectService.id(projectId?.id as string); diff --git a/packages/api/src/controllers/Memberships.ts b/packages/api/src/controllers/Memberships.ts index 70bf575..ab194c7 100644 --- a/packages/api/src/controllers/Memberships.ts +++ b/packages/api/src/controllers/Memberships.ts @@ -1,12 +1,7 @@ import { Controller, Middleware, Post } from "@overnightjs/core"; import { MembershipSchemas, UtilitySchemas } from "@plunk/shared"; import type { Request, Response } from "express"; -import { - HttpException, - NotAllowed, - NotAuthenticated, - NotFound, -} from "../exceptions"; +import { HttpException, NotAllowed, NotAuthenticated, NotFound } from "../exceptions"; import { type IJwt, isAuthenticated } from "../middleware/auth"; import { MembershipService } from "../services/MembershipService"; import { ProjectService } from "../services/ProjectService"; @@ -38,16 +33,10 @@ export class Memberships { const invitedUser = await UserService.email(email); if (!invitedUser) { - throw new HttpException( - 404, - "We could not find that user, please ask them to sign up first.", - ); + throw new HttpException(404, "We could not find that user, please ask them to sign up first."); } - const alreadyMember = await MembershipService.isMember( - project.id, - invitedUser.id, - ); + const alreadyMember = await MembershipService.isMember(project.id, invitedUser.id); if (alreadyMember) { throw new NotAllowed(); @@ -91,10 +80,7 @@ export class Memberships { throw new NotFound("user"); } - const isMember = await MembershipService.isMember( - project.id, - kickedUser.id, - ); + const isMember = await MembershipService.isMember(project.id, kickedUser.id); if (!isMember) { throw new NotAllowed(); diff --git a/packages/api/src/controllers/Projects.ts b/packages/api/src/controllers/Projects.ts index d483703..caa27c5 100644 --- a/packages/api/src/controllers/Projects.ts +++ b/packages/api/src/controllers/Projects.ts @@ -1,11 +1,4 @@ -import { - Controller, - Delete, - Get, - Middleware, - Post, - Put, -} from "@overnightjs/core"; +import { Controller, Delete, Get, Middleware, Post, Put } from "@overnightjs/core"; import { IdentitySchemas, ProjectSchemas, UtilitySchemas } from "@plunk/shared"; import type { Request, Response } from "express"; import z from "zod"; @@ -197,10 +190,7 @@ export class Projects { const contacts = await prisma.contact.findMany({ where: { projectId: project.id, - OR: [ - { email: { contains: query, mode: "insensitive" } }, - { data: { contains: query, mode: "insensitive" } }, - ], + OR: [{ email: { contains: query, mode: "insensitive" } }, { data: { contains: query, mode: "insensitive" } }], }, select: { id: true, diff --git a/packages/api/src/controllers/Tasks.ts b/packages/api/src/controllers/Tasks.ts index b6493be..92532ce 100644 --- a/packages/api/src/controllers/Tasks.ts +++ b/packages/api/src/controllers/Tasks.ts @@ -46,13 +46,7 @@ export class Tasks { if (notevents.length > 0) { const triggers = await ContactService.triggers(contact.id); - if ( - notevents.some((e) => - triggers.some( - (t) => t.contactId === contact.id && t.eventId === e.id, - ), - ) - ) { + if (notevents.some((e) => triggers.some((t) => t.contactId === contact.id && t.eventId === e.id))) { await prisma.task.delete({ where: { id: task.id } }); continue; } @@ -82,10 +76,7 @@ export class Tasks { const { messageId } = await EmailService.send({ from: { name: project.from ?? project.name, - email: - project.verified && project.email - ? project.email - : "no-reply@useplunk.dev", + email: project.verified && project.email ? project.email : "no-reply@useplunk.dev", }, to: [contact.email], content: { @@ -93,9 +84,7 @@ export class Tasks { html: EmailService.compile({ content: body, footer: { - unsubscribe: campaign - ? true - : !!action && action.template.type === "MARKETING", + unsubscribe: campaign ? true : !!action && action.template.type === "MARKETING", }, contact: { id: contact.id, @@ -103,9 +92,7 @@ export class Tasks { project: { name: project.name, }, - isHtml: - (campaign && campaign.style === "HTML") ?? - (!!action && action.template.style === "HTML"), + isHtml: (campaign && campaign.style === "HTML") ?? (!!action && action.template.style === "HTML"), }), }, }); @@ -130,9 +117,7 @@ export class Tasks { await prisma.task.delete({ where: { id: task.id } }); - signale.success( - `Task completed for ${contact.email} from ${project.name}`, - ); + signale.success(`Task completed for ${contact.email} from ${project.name}`); } return res.status(200).json({ success: true }); diff --git a/packages/api/src/controllers/Webhooks/Incoming/SNS.ts b/packages/api/src/controllers/Webhooks/Incoming/SNS.ts index 6b0413a..530f677 100644 --- a/packages/api/src/controllers/Webhooks/Incoming/SNS.ts +++ b/packages/api/src/controllers/Webhooks/Incoming/SNS.ts @@ -43,33 +43,24 @@ export class SNSWebhook { // The email was a transactional email if (email.projectId) { if (body.eventType === "Click") { - signale.success( - `Click received for ${email.contact.email} from ${project.name}`, - ); + signale.success(`Click received for ${email.contact.email} from ${project.name}`); await prisma.click.create({ data: { emailId: email.id, link: body.click.link }, }); } if (body.eventType === "Complaint") { - signale.warn( - `Complaint received for ${email.contact.email} from ${project.name}`, - ); + signale.warn(`Complaint received for ${email.contact.email} from ${project.name}`); } if (body.eventType === "Bounce") { - signale.warn( - `Bounce received for ${email.contact.email} from ${project.name}`, - ); + signale.warn(`Bounce received for ${email.contact.email} from ${project.name}`); } await prisma.email.update({ where: { messageId: body.mail.messageId }, data: { - status: - eventMap[ - body.eventType as "Bounce" | "Delivery" | "Open" | "Complaint" - ], + status: eventMap[body.eventType as "Bounce" | "Delivery" | "Open" | "Complaint"], }, }); @@ -95,9 +86,7 @@ export class SNSWebhook { } if (body.eventType === "Click") { - signale.success( - `Click received for ${email.contact.email} from ${project.name}`, - ); + signale.success(`Click received for ${email.contact.email} from ${project.name}`); await prisma.click.create({ data: { emailId: email.id, link: body.click.link }, @@ -111,12 +100,7 @@ export class SNSWebhook { if (email.action) { event = email.action.template.events.find((e) => e.name.includes( - (body.eventType as - | "Bounce" - | "Delivery" - | "Open" - | "Complaint" - | "Click") === "Delivery" + (body.eventType as "Bounce" | "Delivery" | "Open" | "Complaint" | "Click") === "Delivery" ? "delivered" : "opened", ), @@ -126,12 +110,7 @@ export class SNSWebhook { if (email.campaign) { event = email.campaign.events.find((e) => e.name.includes( - (body.eventType as - | "Bounce" - | "Delivery" - | "Open" - | "Complaint" - | "Click") === "Delivery" + (body.eventType as "Bounce" | "Delivery" | "Open" | "Complaint" | "Click") === "Delivery" ? "delivered" : "opened", ), @@ -144,9 +123,7 @@ export class SNSWebhook { switch (body.eventType as "Delivery" | "Open") { case "Delivery": - signale.success( - `Delivery received for ${email.contact.email} from ${project.name}`, - ); + signale.success(`Delivery received for ${email.contact.email} from ${project.name}`); await prisma.email.update({ where: { messageId: body.mail.messageId }, data: { status: "DELIVERED" }, @@ -158,9 +135,7 @@ export class SNSWebhook { break; case "Open": - signale.success( - `Open received for ${email.contact.email} from ${project.name}`, - ); + signale.success(`Open received for ${email.contact.email} from ${project.name}`); await prisma.email.update({ where: { messageId: body.mail.messageId }, data: { status: "OPENED" }, diff --git a/packages/api/src/controllers/v1/Actions.ts b/packages/api/src/controllers/v1/Actions.ts index 14f1b9d..cae7fae 100644 --- a/packages/api/src/controllers/v1/Actions.ts +++ b/packages/api/src/controllers/v1/Actions.ts @@ -1,21 +1,9 @@ -import { - Controller, - Delete, - Get, - Middleware, - Post, - Put, -} from "@overnightjs/core"; +import { Controller, Delete, Get, Middleware, Post, Put } from "@overnightjs/core"; import { ActionSchemas, UtilitySchemas } from "@plunk/shared"; import type { Request, Response } from "express"; import { prisma } from "../../database/prisma"; import { NotFound } from "../../exceptions"; -import { - type IJwt, - type ISecret, - isAuthenticated, - isValidSecretKey, -} from "../../middleware/auth"; +import { type IJwt, type ISecret, isAuthenticated, isValidSecretKey } from "../../middleware/auth"; import { ActionService } from "../../services/ActionService"; import { EventService } from "../../services/EventService"; import { MembershipService } from "../../services/MembershipService"; @@ -83,14 +71,7 @@ export class Actions { throw new NotFound("project"); } - const { - name, - runOnce, - delay, - template: templateId, - events, - notevents, - } = ActionSchemas.create.parse(req.body); + const { name, runOnce, delay, template: templateId, events, notevents } = ActionSchemas.create.parse(req.body); const template = await TemplateService.id(templateId); @@ -160,15 +141,7 @@ export class Actions { throw new NotFound("project"); } - const { - id, - template: templateId, - events, - notevents, - name, - runOnce, - delay, - } = ActionSchemas.update.parse(req.body); + const { id, template: templateId, events, notevents, name, runOnce, delay } = ActionSchemas.update.parse(req.body); let action = await ActionService.id(id); diff --git a/packages/api/src/controllers/v1/Campaigns.ts b/packages/api/src/controllers/v1/Campaigns.ts index cdb704a..9b12b53 100644 --- a/packages/api/src/controllers/v1/Campaigns.ts +++ b/packages/api/src/controllers/v1/Campaigns.ts @@ -1,22 +1,10 @@ -import { - Controller, - Delete, - Get, - Middleware, - Post, - Put, -} from "@overnightjs/core"; +import { Controller, Delete, Get, Middleware, Post, Put } from "@overnightjs/core"; import { CampaignSchemas, UtilitySchemas } from "@plunk/shared"; import dayjs from "dayjs"; import type { Request, Response } from "express"; import { prisma } from "../../database/prisma"; import { HttpException, NotFound } from "../../exceptions"; -import { - type IJwt, - type ISecret, - isAuthenticated, - isValidSecretKey, -} from "../../middleware/auth"; +import { type IJwt, type ISecret, isAuthenticated, isValidSecretKey } from "../../middleware/auth"; import { CampaignService } from "../../services/CampaignService"; import { EmailService } from "../../services/EmailService"; import { MembershipService } from "../../services/MembershipService"; @@ -39,10 +27,7 @@ export class Campaigns { throw new NotFound("campaign"); } - const isMember = await MembershipService.isMember( - campaign.projectId, - userId, - ); + const isMember = await MembershipService.isMember(campaign.projectId, userId); if (!isMember) { throw new NotFound("campaign"); @@ -122,10 +107,7 @@ export class Campaigns { await EmailService.send({ from: { name: project.from ?? project.name, - email: - project.verified && project.email - ? project.email - : "no-reply@useplunk.dev", + email: project.verified && project.email ? project.email : "no-reply@useplunk.dev", }, to: members.map((m) => m.email), content: { @@ -197,9 +179,7 @@ export class Campaigns { throw new NotFound("project"); } - let { subject, body, recipients, style } = CampaignSchemas.create.parse( - req.body, - ); + let { subject, body, recipients, style } = CampaignSchemas.create.parse(req.body); if (recipients.length === 1 && recipients[0] === "all") { const projectContacts = await prisma.contact.findMany({ @@ -251,9 +231,7 @@ export class Campaigns { } // eslint-disable-next-line prefer-const - let { id, subject, body, recipients, style } = CampaignSchemas.update.parse( - req.body, - ); + let { id, subject, body, recipients, style } = CampaignSchemas.update.parse(req.body); if (recipients.length === 1 && recipients[0] === "all") { const projectContacts = await prisma.contact.findMany({ diff --git a/packages/api/src/controllers/v1/Contacts.ts b/packages/api/src/controllers/v1/Contacts.ts index 95f660c..2c238f8 100644 --- a/packages/api/src/controllers/v1/Contacts.ts +++ b/packages/api/src/controllers/v1/Contacts.ts @@ -1,22 +1,10 @@ -import { - Controller, - Delete, - Get, - Middleware, - Post, - Put, -} from "@overnightjs/core"; +import { Controller, Delete, Get, Middleware, Post, Put } from "@overnightjs/core"; import { ContactSchemas, UtilitySchemas } from "@plunk/shared"; import type { Request, Response } from "express"; import z from "zod"; import { prisma } from "../../database/prisma"; import { HttpException, NotFound } from "../../exceptions"; -import { - type IKey, - type ISecret, - isValidKey, - isValidSecretKey, -} from "../../middleware/auth"; +import { type IKey, type ISecret, isValidKey, isValidSecretKey } from "../../middleware/auth"; import { ActionService } from "../../services/ActionService"; import { ContactService } from "../../services/ContactService"; import { EventService } from "../../services/EventService"; @@ -150,9 +138,7 @@ export class Contacts { await redis.del(Keys.Contact.id(contact.id)); await redis.del(Keys.Contact.email(project.id, contact.email)); - return res - .status(200) - .json({ success: true, contact: contact.id, subscribed: false }); + return res.status(200).json({ success: true, contact: contact.id, subscribed: false }); } @Post("subscribe") @@ -203,9 +189,7 @@ export class Contacts { await redis.del(Keys.Contact.id(contact.id)); await redis.del(Keys.Contact.email(project.id, contact.email)); - return res - .status(200) - .json({ success: true, contact: contact.id, subscribed: true }); + return res.status(200).json({ success: true, contact: contact.id, subscribed: true }); } @Post() @@ -262,9 +246,7 @@ export class Contacts { throw new NotFound("project"); } - const { id, email, subscribed, data } = ContactSchemas.update.parse( - req.body, - ); + const { id, email, subscribed, data } = ContactSchemas.update.parse(req.body); let contact = await ContactService.id(id); diff --git a/packages/api/src/controllers/v1/Templates.ts b/packages/api/src/controllers/v1/Templates.ts index 4160b78..2089c20 100644 --- a/packages/api/src/controllers/v1/Templates.ts +++ b/packages/api/src/controllers/v1/Templates.ts @@ -1,22 +1,9 @@ -import { randomBytes } from "node:crypto"; -import { - Controller, - Delete, - Get, - Middleware, - Post, - Put, -} from "@overnightjs/core"; +import { Controller, Delete, Get, Middleware, Post, Put } from "@overnightjs/core"; import { TemplateSchemas, UtilitySchemas } from "@plunk/shared"; import type { Request, Response } from "express"; import { prisma } from "../../database/prisma"; import { NotAllowed, NotFound } from "../../exceptions"; -import { - type IJwt, - type ISecret, - isAuthenticated, - isValidSecretKey, -} from "../../middleware/auth"; +import { type IJwt, type ISecret, isAuthenticated, isValidSecretKey } from "../../middleware/auth"; import { MembershipService } from "../../services/MembershipService"; import { ProjectService } from "../../services/ProjectService"; import { TemplateService } from "../../services/TemplateService"; @@ -38,10 +25,7 @@ export class Templates { throw new NotFound("template"); } - const isMember = await MembershipService.isMember( - template.projectId, - userId, - ); + const isMember = await MembershipService.isMember(template.projectId, userId); if (!isMember) { throw new NotFound("template"); @@ -117,9 +101,7 @@ export class Templates { throw new NotFound("project"); } - const { subject, body, type, style } = TemplateSchemas.create.parse( - req.body, - ); + const { subject, body, type, style } = TemplateSchemas.create.parse(req.body); const template = await prisma.template.create({ data: { @@ -169,9 +151,7 @@ export class Templates { throw new NotFound("project"); } - const { id, subject, body, type, style } = TemplateSchemas.update.parse( - req.body, - ); + const { id, subject, body, type, style } = TemplateSchemas.update.parse(req.body); let template = await TemplateService.id(id); @@ -238,9 +218,7 @@ export class Templates { const actions = await TemplateService.actions(id); if (actions && actions.length > 0) { - throw new NotAllowed( - "This template is being used by an action. Unlink the action before deleting the template.", - ); + throw new NotAllowed("This template is being used by an action. Unlink the action before deleting the template."); } await prisma.template.delete({ where: { id } }); diff --git a/packages/api/src/controllers/v1/index.ts b/packages/api/src/controllers/v1/index.ts index 546a635..7137b21 100644 --- a/packages/api/src/controllers/v1/index.ts +++ b/packages/api/src/controllers/v1/index.ts @@ -1,21 +1,11 @@ -import { - ChildControllers, - Controller, - Middleware, - Post, -} from "@overnightjs/core"; +import { ChildControllers, Controller, Middleware, Post } from "@overnightjs/core"; import { EventSchemas } from "@plunk/shared"; import dayjs from "dayjs"; import type { Request, Response } from "express"; import signale from "signale"; import { prisma } from "../../database/prisma"; import { HttpException, NotAllowed } from "../../exceptions"; -import { - type IKey, - type ISecret, - isValidKey, - isValidSecretKey, -} from "../../middleware/auth"; +import { type IKey, type ISecret, isValidKey, isValidSecretKey } from "../../middleware/auth"; import { ActionService } from "../../services/ActionService"; import { ContactService } from "../../services/ContactService"; import { EmailService } from "../../services/EmailService"; @@ -30,13 +20,7 @@ import { Events } from "./Events"; import { Templates } from "./Templates"; @Controller("v1") -@ChildControllers([ - new Actions(), - new Templates(), - new Campaigns(), - new Contacts(), - new Events(), -]) +@ChildControllers([new Actions(), new Templates(), new Campaigns(), new Contacts(), new Events()]) export class V1 { @Post() @Post("track") @@ -53,14 +37,9 @@ export class V1 { const result = EventSchemas.post.safeParse(req.body); if (!result.success) { - signale.warn( - `${project.name} tried tracking an event with invalid data: ${JSON.stringify(req.body)}`, - ); + signale.warn(`${project.name} tried tracking an event with invalid data: ${JSON.stringify(req.body)}`); if ("unionErrors" in result.error.issues[0]) { - throw new HttpException( - 400, - result.error.issues[0].unionErrors[0].errors[0].message, - ); + throw new HttpException(400, result.error.issues[0].unionErrors[0].errors[0].message); } throw new HttpException(400, result.error.issues[0].message); @@ -78,10 +57,7 @@ export class V1 { event = await prisma.event.create({ data: { name, projectId: project.id }, }); - redis.set( - Keys.Event.event(project.id, event.name), - JSON.stringify(event), - ); + redis.set(Keys.Event.event(project.id, event.name), JSON.stringify(event)); redis.set(Keys.Event.id(event.id), JSON.stringify(event)); redis.del(Keys.Project.events(project.id, true)); @@ -139,9 +115,7 @@ export class V1 { void ActionService.trigger({ event, contact, project }); - signale.success( - `${project.name} triggered ${event.name} for ${contact.email}`, - ); + signale.success(`${project.name} triggered ${event.name} for ${contact.email}`); return res.status(200).json({ success: true, @@ -166,30 +140,20 @@ export class V1 { if (!result.success) { if ("unionErrors" in result.error.issues[0]) { - throw new HttpException( - 400, - result.error.issues[0].unionErrors[0].errors[0].message, - ); + throw new HttpException(400, result.error.issues[0].unionErrors[0].errors[0].message); } throw new HttpException(400, result.error.issues[0].message); } - const { from, name, reply, to, subject, body, subscribed, headers } = - result.data; + const { from, name, reply, to, subject, body, subscribed, headers } = result.data; if (!project.email || !project.verified) { - throw new HttpException( - 401, - "Verify your domain before you start sending", - ); + throw new HttpException(401, "Verify your domain before you start sending"); } if (from && from.split("@")[1] !== project.email?.split("@")[1]) { - throw new HttpException( - 401, - "Custom from address must be from a verified domain", - ); + throw new HttpException(401, "Custom from address must be from a verified domain"); } const emails: { @@ -231,16 +195,15 @@ export class V1 { } } - const { subject: enrichedSubject, body: enrichedBody } = - EmailService.format({ - subject, - body, - data: { - plunk_id: contact.id, - plunk_email: contact.email, - ...JSON.parse(contact.data ?? "{}"), - }, - }); + const { subject: enrichedSubject, body: enrichedBody } = EmailService.format({ + subject, + body, + data: { + plunk_id: contact.id, + plunk_email: contact.email, + ...JSON.parse(contact.data ?? "{}"), + }, + }); const { messageId } = await EmailService.send({ from: { @@ -287,12 +250,8 @@ export class V1 { redis.del(Keys.Project.emails(project.id)); redis.del(Keys.Project.emails(project.id, { count: true })); - signale.success( - `${project.name} sent a transactional email to ${to.join(", ")}`, - ); + signale.success(`${project.name} sent a transactional email to ${to.join(", ")}`); - return res - .status(200) - .json({ success: true, emails, timestamp: dayjs().toISOString() }); + return res.status(200).json({ success: true, emails, timestamp: dayjs().toISOString() }); } } diff --git a/packages/api/src/middleware/auth.ts b/packages/api/src/middleware/auth.ts index 528a801..05167e7 100644 --- a/packages/api/src/middleware/auth.ts +++ b/packages/api/src/middleware/auth.ts @@ -25,11 +25,7 @@ export interface IKey { * @param res * @param next */ -export const isAuthenticated = ( - req: Request, - res: Response, - next: NextFunction, -) => { +export const isAuthenticated = (req: Request, res: Response, next: NextFunction) => { res.locals.auth = { type: "jwt", userId: parseJwt(req) }; next(); @@ -41,11 +37,7 @@ export const isAuthenticated = ( * @param res * @param next */ -export const isValidSecretKey = ( - req: Request, - res: Response, - next: NextFunction, -) => { +export const isValidSecretKey = (req: Request, res: Response, next: NextFunction) => { res.locals.auth = { type: "secret", sk: parseBearer(req, "secret") }; next(); @@ -118,10 +110,7 @@ export function parseJwt(request: Request): string { * @param request The express request object * @param type */ -export function parseBearer( - request: Request, - type?: "secret" | "public", -): string { +export function parseBearer(request: Request, type?: "secret" | "public"): string { const bearer: string | undefined = request.headers.authorization; if (!bearer) { @@ -135,17 +124,11 @@ export function parseBearer( const split = bearer.split(" "); if (!(split[0] === "Bearer") || split.length > 2) { - throw new HttpException( - 401, - "Your authorization header is malformed. Please pass your API key as Bearer sk_...", - ); + throw new HttpException(401, "Your authorization header is malformed. Please pass your API key as Bearer sk_..."); } if (!type && !split[1].startsWith("sk_") && !split[1].startsWith("pk_")) { - throw new HttpException( - 401, - "Your API key could not be parsed. API keys start with sk_ or pk_", - ); + throw new HttpException(401, "Your API key could not be parsed. API keys start with sk_ or pk_"); } if (!type) { @@ -153,10 +136,7 @@ export function parseBearer( } if (type === "secret" && split[1].startsWith("pk_")) { - throw new HttpException( - 401, - "You attached a public key but this route may only be accessed with a secret key", - ); + throw new HttpException(401, "You attached a public key but this route may only be accessed with a secret key"); } if (type === "secret" && !split[1].startsWith("sk_")) { diff --git a/packages/api/src/services/ActionService.ts b/packages/api/src/services/ActionService.ts index 41fef46..26a88fa 100644 --- a/packages/api/src/services/ActionService.ts +++ b/packages/api/src/services/ActionService.ts @@ -54,11 +54,9 @@ export class ActionService { */ public static event(eventId: string) { return wrapRedis(Keys.Action.event(eventId), async () => { - return prisma.event - .findUniqueOrThrow({ where: { id: eventId } }) - .actions({ - include: { events: true, template: true, notevents: true }, - }); + return prisma.event.findUniqueOrThrow({ where: { id: eventId } }).actions({ + include: { events: true, template: true, notevents: true }, + }); }); } @@ -68,52 +66,35 @@ export class ActionService { * @param event * @param project */ - public static async trigger({ - event, - contact, - project, - }: { event: Event; contact: Contact; project: Project }) { + public static async trigger({ event, contact, project }: { event: Event; contact: Contact; project: Project }) { const actions = await ActionService.event(event.id); const triggers = await ContactService.triggers(contact.id); for (const action of actions) { - const hasTriggeredAction = !!triggers.find( - (t) => t.actionId === action.id, - ); + const hasTriggeredAction = !!triggers.find((t) => t.actionId === action.id); if (action.runOnce && hasTriggeredAction) { // User has already triggered this run once action continue; } - if ( - action.notevents.length > 0 && - action.notevents.some((e) => triggers.some((t) => t.eventId === e.id)) - ) { + if (action.notevents.length > 0 && action.notevents.some((e) => triggers.some((t) => t.eventId === e.id))) { continue; } let triggeredEvents = triggers.filter((t) => t.eventId === event.id); if (hasTriggeredAction) { - const lastActionTrigger = triggers.filter( - (t) => t.contactId === contact.id && t.actionId === action.id, - )[0]; + const lastActionTrigger = triggers.filter((t) => t.contactId === contact.id && t.actionId === action.id)[0]; - triggeredEvents = triggeredEvents.filter( - (e) => e.createdAt > lastActionTrigger.createdAt, - ); + triggeredEvents = triggeredEvents.filter((e) => e.createdAt > lastActionTrigger.createdAt); } - const updatedTriggers = [ - ...new Set(triggeredEvents.map((t) => t.eventId)), - ]; + const updatedTriggers = [...new Set(triggeredEvents.map((t) => t.eventId))]; const requiredTriggers = action.events.map((e) => e.id); - if ( - updatedTriggers.sort().join(",") !== requiredTriggers.sort().join(",") - ) { + if (updatedTriggers.sort().join(",") !== requiredTriggers.sort().join(",")) { // Not all required events have been triggered continue; } @@ -140,10 +121,7 @@ export class ActionService { const { messageId } = await EmailService.send({ from: { name: project.from ?? project.name, - email: - project.verified && project.email - ? project.email - : "no-reply@useplunk.dev", + email: project.verified && project.email ? project.email : "no-reply@useplunk.dev", }, to: [contact.email], content: { diff --git a/packages/api/src/services/EmailService.ts b/packages/api/src/services/EmailService.ts index a25eb3e..a83ad29 100644 --- a/packages/api/src/services/EmailService.ts +++ b/packages/api/src/services/EmailService.ts @@ -467,8 +467,8 @@ ${ ${ - footer.unsubscribe - ? ` + footer.unsubscribe + ? `

@@ -476,8 +476,8 @@ ${

` - : "" - } + : "" + }
@@ -485,22 +485,14 @@ ${ ).html.replace(/^\s+|\s+$/g, ""); } - public static format({ - subject, - body, - data, - }: { subject: string; body: string; data: Record }) { + public static format({ subject, body, data }: { subject: string; body: string; data: Record }) { return { subject: subject.replace(/\{\{(.*?)}}/g, (match, key) => { - const [mainKey, defaultValue] = key - .split("??") - .map((s: string) => s.trim()); + const [mainKey, defaultValue] = key.split("??").map((s: string) => s.trim()); return data[mainKey] ?? defaultValue ?? ""; }), body: body.replace(/\{\{(.*?)}}/g, (match, key) => { - const [mainKey, defaultValue] = key - .split("??") - .map((s: string) => s.trim()); + const [mainKey, defaultValue] = key.split("??").map((s: string) => s.trim()); if (Array.isArray(data[mainKey])) { return data[mainKey].map((e: string) => `
  • ${e}
  • `).join("\n"); } diff --git a/packages/api/src/services/MembershipService.ts b/packages/api/src/services/MembershipService.ts index 46b380b..8931cda 100644 --- a/packages/api/src/services/MembershipService.ts +++ b/packages/api/src/services/MembershipService.ts @@ -6,54 +6,45 @@ import { redis, wrapRedis } from "./redis"; export class MembershipService { public static async isMember(projectId: string, userId: string) { - return wrapRedis( - Keys.ProjectMembership.isMember(projectId, userId), - async () => { - if (NODE_ENV === "development") { - return true; - } + return wrapRedis(Keys.ProjectMembership.isMember(projectId, userId), async () => { + if (NODE_ENV === "development") { + return true; + } - const membership = await prisma.projectMembership.findFirst({ - where: { projectId, userId }, - }); + const membership = await prisma.projectMembership.findFirst({ + where: { projectId, userId }, + }); - return !!membership; - }, - ); + return !!membership; + }); } public static async isAdmin(projectId: string, userId: string) { - return wrapRedis( - Keys.ProjectMembership.isAdmin(projectId, userId), - async () => { - if (NODE_ENV === "development") { - return true; - } + return wrapRedis(Keys.ProjectMembership.isAdmin(projectId, userId), async () => { + if (NODE_ENV === "development") { + return true; + } - const membership = await prisma.projectMembership.findFirst({ - where: { projectId, userId, role: { in: ["ADMIN", "OWNER"] } }, - }); + const membership = await prisma.projectMembership.findFirst({ + where: { projectId, userId, role: { in: ["ADMIN", "OWNER"] } }, + }); - return !!membership; - }, - ); + return !!membership; + }); } public static async isOwner(projectId: string, userId: string) { - return wrapRedis( - Keys.ProjectMembership.isOwner(projectId, userId), - async () => { - if (NODE_ENV === "development") { - return true; - } + return wrapRedis(Keys.ProjectMembership.isOwner(projectId, userId), async () => { + if (NODE_ENV === "development") { + return true; + } - const membership = await prisma.projectMembership.findFirst({ - where: { projectId, userId, role: "OWNER" }, - }); + const membership = await prisma.projectMembership.findFirst({ + where: { projectId, userId, role: "OWNER" }, + }); - return !!membership; - }, - ); + return !!membership; + }); } public static async kick(projectId: string, userId: string) { diff --git a/packages/api/src/services/ProjectService.ts b/packages/api/src/services/ProjectService.ts index b7e4497..6490031 100644 --- a/packages/api/src/services/ProjectService.ts +++ b/packages/api/src/services/ProjectService.ts @@ -51,11 +51,7 @@ export class ProjectService { return wrapRedis(Keys.Project.emails(id), async () => { return prisma.email.findMany({ where: { - OR: [ - { action: { projectId: id } }, - { campaign: { projectId: id } }, - { projectId: id }, - ], + OR: [{ action: { projectId: id } }, { campaign: { projectId: id } }, { projectId: id }], }, orderBy: { createdAt: "desc" }, }); @@ -107,9 +103,7 @@ export class ProjectService { public static memberships(id: string) { return wrapRedis(Keys.Project.memberships(id), async () => { - const memberships = await prisma.project - .findUnique({ where: { id } }) - .memberships({ include: { user: true } }); + const memberships = await prisma.project.findUnique({ where: { id } }).memberships({ include: { user: true } }); if (!memberships) { return []; @@ -127,31 +121,23 @@ export class ProjectService { public static metadata(id: string) { return wrapRedis(Keys.Project.metadata(id), async () => { - const contacts = await prisma.project - .findUnique({ where: { id } }) - .contacts({ - where: { - data: { - not: null, - }, + const contacts = await prisma.project.findUnique({ where: { id } }).contacts({ + where: { + data: { + not: null, }, - distinct: ["data"], - select: { - data: true, - }, - }); + }, + distinct: ["data"], + select: { + data: true, + }, + }); if (!contacts) { return []; } - return [ - ...new Set( - contacts - .filter((c) => c.data) - .flatMap((c) => Object.keys(JSON.parse(c.data as string))), - ), - ]; + return [...new Set(contacts.filter((c) => c.data).flatMap((c) => Object.keys(JSON.parse(c.data as string))))]; }); } diff --git a/packages/api/src/util/ses.ts b/packages/api/src/util/ses.ts index b56b692..91f28d0 100644 --- a/packages/api/src/util/ses.ts +++ b/packages/api/src/util/ses.ts @@ -1,9 +1,5 @@ import { SES } from "@aws-sdk/client-ses"; -import { - AWS_ACCESS_KEY_ID, - AWS_REGION, - AWS_SECRET_ACCESS_KEY, -} from "../app/constants"; +import { AWS_ACCESS_KEY_ID, AWS_REGION, AWS_SECRET_ACCESS_KEY } from "../app/constants"; export const ses = new SES({ apiVersion: "2010-12-01", diff --git a/packages/dashboard/.env.example b/packages/dashboard/.env.example index 033bdd2..62720b7 100644 --- a/packages/dashboard/.env.example +++ b/packages/dashboard/.env.example @@ -1,2 +1,2 @@ -NEXT_PUBLIC_API_URI=http://localhost:8080 +NEXT_PUBLIC_API_URI=http://localhost:4000 NEXT_PUBLIC_AWS_REGION=eu-west-3 \ No newline at end of file diff --git a/packages/dashboard/src/components/Input/MarkdownEditor/Editor.tsx b/packages/dashboard/src/components/Input/MarkdownEditor/Editor.tsx index e9078da..d9ecfd6 100644 --- a/packages/dashboard/src/components/Input/MarkdownEditor/Editor.tsx +++ b/packages/dashboard/src/components/Input/MarkdownEditor/Editor.tsx @@ -9,7 +9,6 @@ import { AnimatePresence, motion } from "framer-motion"; import React, { useCallback, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { API_URI } from "../../../lib/constants"; import { Modal } from "../../Overlay"; import "tippy.js/animations/scale.css"; import HTMLEditor from "@monaco-editor/react"; @@ -18,15 +17,7 @@ import { Dropcursor } from "@tiptap/extension-dropcursor"; import FontFamily from "@tiptap/extension-font-family"; import { TextAlign } from "@tiptap/extension-text-align"; import { TextStyle } from "@tiptap/extension-text-style"; -import { - AlignCenter, - AlignLeft, - AlignRight, - ImageIcon, - Inspect, - LinkIcon, -} from "lucide-react"; -import { toast } from "sonner"; +import { AlignCenter, AlignLeft, AlignRight, ImageIcon, Inspect, LinkIcon } from "lucide-react"; import { Dropdown } from "../Dropdown"; import { Button } from "./extensions/Button"; import { EditorBubbleMenu } from "./extensions/EditorBubbleMenu"; @@ -48,20 +39,13 @@ export interface MarkdownEditorProps { * @param root0.value * @param root0.onChange */ -export default function Editor({ - value, - onChange, - mode, - modeSwitcher, -}: MarkdownEditorProps) { +export default function Editor({ value, onChange, mode, modeSwitcher }: MarkdownEditorProps) { const [imageModal, setImageModal] = useState(false); const [urlModal, setUrlModal] = useState(false); const [barModal, setBarModal] = useState(false); const [buttonModal, setButtonModal] = useState(false); const [confirmModal, setConfirmModal] = useState(false); - const fileInput = useRef(null); - const editor = useEditor({ extensions: [ Slash, @@ -144,9 +128,7 @@ export default function Editor({ z.object({ url: z .string() - .regex( - /^(?:https?:\/\/)?(?:\{\{[\w-]+}}|(?:[\w-]+\.)+[a-z]{2,})(?:\/\S*)?(?:\?\S*)?$/, - ) + .regex(/^(?:https?:\/\/)?(?:\{\{[\w-]+}}|(?:[\w-]+\.)+[a-z]{2,})(?:\/\S*)?(?:\?\S*)?$/) .transform((u) => { if (u.startsWith("{{") && u.endsWith("}}")) { return u; @@ -171,23 +153,8 @@ export default function Editor({ }>({ resolver: zodResolver( z.object({ - percent: z.preprocess( - (a) => Number.parseInt(z.string().parse(a), 10), - z.number().positive().max(100), - ), - color: z - .enum([ - "red", - "yellow", - "green", - "blue", - "indigo", - "purple", - "pink", - "orange", - "black", - ]) - .default("blue"), + percent: z.preprocess((a) => Number.parseInt(z.string().parse(a), 10), z.number().positive().max(100)), + color: z.enum(["red", "yellow", "green", "blue", "indigo", "purple", "pink", "orange", "black"]).default("blue"), }), ), defaultValues: { @@ -210,9 +177,7 @@ export default function Editor({ z.object({ link: z .string() - .regex( - /^(?:https?:\/\/)?(?:\{\{[\w-]+}}|(?:[\w-]+\.)+[a-z]{2,})(?:\/\S*)?$/, - ) + .regex(/^(?:https?:\/\/)?(?:\{\{[\w-]+}}|(?:[\w-]+\.)+[a-z]{2,})(?:\/\S*)?$/) .transform((u) => { if (u.startsWith("{{") && u.endsWith("}}")) { return u; @@ -220,19 +185,7 @@ export default function Editor({ return u.startsWith("http") ? u : `https://${u}`; }), - color: z - .enum([ - "red", - "yellow", - "green", - "blue", - "indigo", - "purple", - "pink", - "orange", - "black", - ]) - .default("blue"), + color: z.enum(["red", "yellow", "green", "blue", "indigo", "purple", "pink", "orange", "black"]).default("blue"), }), ), defaultValues: { @@ -250,11 +203,7 @@ export default function Editor({ const addBar = useCallback( (data: { percent: number; color: colors }) => { - editor - ?.chain() - .focus() - .setProgress({ percent: data.percent, color: data.color }) - .run(); + editor?.chain().focus().setProgress({ percent: data.percent, color: data.color }).run(); setBarModal(false); resetBar(); }, @@ -263,11 +212,7 @@ export default function Editor({ const addButton = useCallback( (data: { link: string; color: colors }) => { - editor - ?.chain() - .focus() - .setButton({ href: data.link, color: data.color }) - .run(); + editor?.chain().focus().setButton({ href: data.link, color: data.color }).run(); setButtonModal(false); resetButton(); }, @@ -276,11 +221,7 @@ export default function Editor({ const addUrl = useCallback( (data: { url: string }) => { - editor - ?.chain() - .focus() - .setLink({ href: data.url, target: "_blank" }) - .run(); + editor?.chain().focus().setLink({ href: data.url, target: "_blank" }).run(); setUrlModal(false); resetUrl(); }, @@ -311,9 +252,8 @@ export default function Editor({ >

    - Are you sure you want to switch to{" "} - {mode === "PLUNK" ? "HTML" : "the Plunk Editor"}?
    This will - clear your current content. + Are you sure you want to switch to {mode === "PLUNK" ? "HTML" : "the Plunk Editor"}?
    This will clear your + current content.

    @@ -358,15 +298,9 @@ export default function Editor({ } > -
    +
    -