Initial Commit

This commit is contained in:
Dries Augustyns
2024-07-23 13:49:48 +02:00
commit 91a8840c3c
191 changed files with 36022 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
import "dotenv/config";
import "express-async-errors";
import { STATUS_CODES } from "node:http";
import { Server } from "@overnightjs/core";
import compression from "compression";
import cookies from "cookie-parser";
import cors from "cors";
import { type NextFunction, type Request, type Response, json } from "express";
import helmet from "helmet";
import morgan from "morgan";
import signale from "signale";
import { API_URI, NODE_ENV, PORT } from "./app/constants";
import { task } from "./app/cron";
import { Auth } from "./controllers/Auth";
import { Identities } from "./controllers/Identities";
import { Memberships } from "./controllers/Memberships";
import { Projects } from "./controllers/Projects";
import { Tasks } from "./controllers/Tasks";
import { Users } from "./controllers/Users";
import { Webhooks } from "./controllers/Webhooks";
import { V1 } from "./controllers/v1";
import { prisma } from "./database/prisma";
import { HttpException } from "./exceptions";
const server = new (class extends Server {
public constructor() {
super();
// Set the content-type to JSON for any request coming from AWS SNS
this.app.use((req, res, next) => {
if (req.get("x-amz-sns-message-type")) {
req.headers["content-type"] = "application/json";
}
next();
});
this.app.use(
compression({
threshold: 0,
}),
);
// Parse the rest of our application as json
this.app.use(json({ limit: "50mb" }));
this.app.use(cookies());
this.app.use(helmet());
this.app.use(["/v1", "/v1/track", "/v1/send"], (req, res, next) => {
res.set({ "Access-Control-Allow-Origin": "*" });
next();
});
this.app.use(
cors({
origin: [API_URI],
credentials: true,
}),
);
this.app.use(morgan(NODE_ENV === "development" ? "dev" : "short"));
this.addControllers([
new Auth(),
new Users(),
new Projects(),
new Memberships(),
new Webhooks(),
new Identities(),
new Tasks(),
new V1(),
]);
this.app.use("*", () => {
throw new HttpException(404, "Unknown route");
});
}
})();
server.app.use((req, res, next) => {
console.log(`Incoming request: ${req.method} ${req.path}`);
next();
});
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);
}
res.status(code).json({
code,
error: STATUS_CODES[code],
message: error.message,
time: Date.now(),
});
},
);
void prisma.$connect().then(() => {
server.app.listen(PORT, () => {
task.start();
signale.success("[HTTPS] Ready on", PORT);
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* Safely parse environment variables
* @param key The key
* @param defaultValue An optional default value if the environment variable does not exist
*/
export function validateEnv<T extends string = string>(
key: keyof NodeJS.ProcessEnv,
defaultValue?: T,
): T {
const value = process.env[key] as T | undefined;
if (!value) {
if (typeof defaultValue !== "undefined") {
return defaultValue;
}
throw new Error(`${key} is not defined in environment variables`);
}
return value;
}
// ENV
export const JWT_SECRET = validateEnv("JWT_SECRET");
export const PORT = validateEnv<`${number}`>("PORT", "4000");
export const NODE_ENV = validateEnv<"development" | "production">(
"NODE_ENV",
"production",
);
export const REDIS_URL = validateEnv("REDIS_URL");
// URLs
export const API_URI = validateEnv("API_URI", "http://localhost:8080");
export const APP_URI = validateEnv("APP_URI", "http://localhost:3000");
// AWS
export const AWS_REGION = validateEnv("AWS_REGION");
export const AWS_ACCESS_KEY_ID = validateEnv("AWS_SES_ACCESS_KEY_ID");
export const AWS_SECRET_ACCESS_KEY = validateEnv("AWS_SES_SECRET_ACCESS_KEY");
export const AWS_SES_CONFIGURATION_SET = validateEnv(
"AWS_SES_CONFIGURATION_SET",
);
+15
View File
@@ -0,0 +1,15 @@
import cron from 'node-cron';
import {API_URI} from './constants';
import signale from 'signale';
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',
});
});
+125
View File
@@ -0,0 +1,125 @@
import { Controller, Get, Post } from "@overnightjs/core";
import { UserSchemas, UtilitySchemas } from "@plunk/shared";
import type { Request, Response } from "express";
import { prisma } from "../database/prisma";
import { NotAllowed, NotFound } from "../exceptions";
import { jwt } from "../middleware/auth";
import { AuthService } from "../services/AuthService";
import { UserService } from "../services/UserService";
import { Keys } from "../services/keys";
import { REDIS_ONE_MINUTE, redis } from "../services/redis";
import { createHash } from "../util/hash";
@Controller("auth")
export class Auth {
@Post("login")
public async login(req: Request, res: Response) {
const { email, password } = UserSchemas.credentials.parse(req.body);
const user = await UserService.email(email);
if (!user) {
return res.json({ success: false, data: "Incorrect email or password" });
}
if (!user.password) {
return res.json({
success: "redirect",
redirect: `/auth/reset?id=${user.id}`,
});
}
const verified = await AuthService.verifyCredentials(email, password);
if (!verified) {
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,
);
const token = jwt.sign(user.id);
const cookie = UserService.cookieOptions();
return res
.cookie(UserService.COOKIE_NAME, token, cookie)
.json({ success: true, data: { id: user.id, email: user.email } });
}
@Post("signup")
public async signup(req: Request, res: Response) {
const { email, password } = UserSchemas.credentials.parse(req.body);
const user = await UserService.email(email);
if (user) {
return res.json({
success: false,
data: "That email is already associated with another user",
});
}
const created_user = await prisma.user.create({
data: {
email,
password: await createHash(password),
},
});
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();
return res.cookie(UserService.COOKIE_NAME, token, cookie).json({
success: true,
data: { id: created_user.id, email: created_user.email },
});
}
@Post("reset")
public async reset(req: Request, res: Response) {
const { id, password } = UtilitySchemas.id
.merge(UserSchemas.credentials.pick({ password: true }))
.parse(req.body);
const user = await UserService.id(id);
if (!user) {
throw new NotFound("user");
}
if (user.password) {
throw new NotAllowed();
}
await prisma.user.update({
where: { id },
data: { password: await createHash(password) },
});
await redis.del(Keys.User.id(user.id));
await redis.del(Keys.User.email(user.email));
return res.json({ success: true });
}
@Get("logout")
public logout(req: Request, res: Response) {
res.cookie(
UserService.COOKIE_NAME,
"",
UserService.cookieOptions(new Date()),
);
return res.json(true);
}
}
+171
View File
@@ -0,0 +1,171 @@
import { Controller, Get, Middleware, Post } from "@overnightjs/core";
import { IdentitySchemas, UtilitySchemas } from "@plunk/shared";
import type { Request, Response } from "express";
import signale from "signale";
import { prisma } from "../database/prisma";
import { NotFound } from "../exceptions";
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";
@Controller("identities")
export class Identities {
@Get("id/:id")
@Middleware([isAuthenticated])
public async getVerification(req: Request, res: Response) {
const { id } = UtilitySchemas.id.parse(req.params);
const project = await ProjectService.id(id);
if (!project) {
throw new NotFound("project");
}
if (!project.email) {
return res.status(200).json({ success: false });
}
const attributes = await getIdentityVerificationAttributes(project.email);
if (attributes.status === "Success" && !project.verified) {
await prisma.project.update({ where: { id }, data: { verified: true } });
await redis.del(Keys.Project.id(project.id));
await redis.del(Keys.Project.secret(project.secret));
await redis.del(Keys.Project.public(project.public));
}
return res.status(200).json({ tokens: attributes.tokens });
}
@Middleware([isAuthenticated])
@Post("create")
public async addIdentity(req: Request, res: Response) {
const { id, email } = IdentitySchemas.create.parse(req.body);
const { userId } = res.locals.auth as IJwt;
const project = await ProjectService.id(id);
if (!project) {
throw new NotFound("project");
}
const existingProject = await prisma.project.findFirst({
where: { email: { endsWith: email.split("@")[1] } },
});
if (existingProject) {
throw new Error("Domain already attached to another project");
}
const tokens = await verifyIdentity(email);
await prisma.project.update({
where: { id },
data: { email, verified: false },
});
await redis.del(Keys.User.projects(userId));
await redis.del(Keys.Project.id(project.id));
return res.status(200).json({ success: true, tokens });
}
@Middleware([isAuthenticated])
@Post("reset")
public async resetIdentity(req: Request, res: Response) {
const { id } = UtilitySchemas.id.parse(req.body);
const { userId } = res.locals.auth as IJwt;
const project = await ProjectService.id(id);
if (!project) {
throw new NotFound("project");
}
await prisma.project.update({
where: { id },
data: { email: null, verified: false },
});
await redis.del(Keys.User.projects(userId));
await redis.del(Keys.Project.id(project.id));
return res.status(200).json({ success: true });
}
@Post("update")
public async updateIdentities(req: Request, res: Response) {
const count = await prisma.project.count({
where: { email: { not: null } },
});
for (let i = 0; i < count; i += 99) {
const dbIdentities = await prisma.project.findMany({
where: { email: { not: null } },
select: { id: true, email: true },
skip: i,
take: 99,
});
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 project = await ProjectService.id(projectId?.id as string);
if (identity.status === "Failed") {
signale.info(`Restarting verification for ${identity.email}`);
try {
void verifyIdentity(identity.email);
} catch (e) {
// @ts-ignore
if (e.Code === "Throttling") {
signale.warn("Throttling detected, waiting 5 seconds");
await new Promise((r) => setTimeout(r, 5000));
}
}
}
await prisma.project.update({
where: { id: projectId?.id as string },
data: { verified: identity.status === "Success" },
});
if (project && !project.verified && identity.status === "Success") {
signale.success(`Successfully verified ${identity.email}`);
void ses.setIdentityFeedbackForwardingEnabled({
Identity: identity.email,
ForwardingEnabled: false,
});
await redis.del(Keys.Project.id(project.id));
await redis.del(Keys.Project.secret(project.secret));
await redis.del(Keys.Project.public(project.public));
}
if (project?.verified && identity.status !== "Success") {
await redis.del(Keys.Project.id(project.id));
await redis.del(Keys.Project.secret(project.secret));
await redis.del(Keys.Project.public(project.public));
}
}
}
return res.status(200).json({ success: true });
}
}
+147
View File
@@ -0,0 +1,147 @@
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 { type IJwt, isAuthenticated } from "../middleware/auth";
import { MembershipService } from "../services/MembershipService";
import { ProjectService } from "../services/ProjectService";
import { UserService } from "../services/UserService";
import { Keys } from "../services/keys";
import { redis } from "../services/redis";
@Controller("memberships")
export class Memberships {
@Middleware([isAuthenticated])
@Post("invite")
public async inviteMember(req: Request, res: Response) {
const { id: projectId, email } = MembershipSchemas.invite.parse(req.body);
const { userId } = res.locals.auth as IJwt;
const project = await ProjectService.id(projectId);
if (!project) {
throw new NotFound("project");
}
const isAdmin = await MembershipService.isAdmin(projectId, userId);
if (!isAdmin) {
throw new NotAllowed();
}
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.",
);
}
const alreadyMember = await MembershipService.isMember(
project.id,
invitedUser.id,
);
if (alreadyMember) {
throw new NotAllowed();
}
await MembershipService.invite(projectId, invitedUser.id, "ADMIN");
const memberships = await ProjectService.memberships(projectId);
return res.status(200).json({ success: true, memberships });
}
@Middleware([isAuthenticated])
@Post("kick")
public async kickMember(req: Request, res: Response) {
const { id: projectId, email } = MembershipSchemas.kick.parse(req.body);
const { userId } = res.locals.auth as IJwt;
const user = await UserService.id(userId);
if (!user) {
throw new NotAuthenticated();
}
const project = await ProjectService.id(projectId);
if (!project) {
throw new NotFound("project");
}
const isAdmin = await MembershipService.isAdmin(projectId, userId);
if (!isAdmin) {
throw new NotAllowed();
}
const kickedUser = await UserService.email(email);
if (!kickedUser) {
throw new NotFound("user");
}
const isMember = await MembershipService.isMember(
project.id,
kickedUser.id,
);
if (!isMember) {
throw new NotAllowed();
}
if (userId === kickedUser.id) {
throw new NotAllowed();
}
await MembershipService.kick(projectId, kickedUser.id);
const memberships = await ProjectService.memberships(projectId);
return res.status(200).json({ success: true, memberships });
}
@Middleware([isAuthenticated])
@Post("leave")
public async leaveProject(req: Request, res: Response) {
const { id: projectId } = UtilitySchemas.id.parse(req.body);
const { userId } = res.locals.auth as IJwt;
const user = await UserService.id(userId);
if (!user) {
throw new NotAuthenticated();
}
const project = await ProjectService.id(projectId);
if (!project) {
throw new NotFound("project");
}
const isMember = await MembershipService.isMember(projectId, userId);
if (!isMember) {
throw new NotAllowed();
}
await MembershipService.kick(projectId, userId);
await redis.del(Keys.User.projects(userId));
const memberships = await UserService.projects(userId);
return res.status(200).json({ success: true, memberships });
}
}
+624
View File
@@ -0,0 +1,624 @@
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";
import { prisma } from "../database/prisma";
import { NotAllowed, NotAuthenticated, NotFound } from "../exceptions";
import { type IJwt, isAuthenticated } from "../middleware/auth";
import { MembershipService } from "../services/MembershipService";
import { ProjectService } from "../services/ProjectService";
import { UserService } from "../services/UserService";
import { Keys } from "../services/keys";
import { redis } from "../services/redis";
import { generateToken } from "../util/tokens";
@Controller("projects")
export class Projects {
@Get("id/:id")
@Middleware([isAuthenticated])
public async getProjectByID(req: Request, res: Response) {
const { id: projectId } = UtilitySchemas.id.parse(req.params);
const { userId } = res.locals.auth as IJwt;
const project = await ProjectService.id(projectId);
if (!project) {
throw new NotFound("project");
}
const isMember = await MembershipService.isMember(projectId, userId);
if (!isMember) {
throw new NotAllowed();
}
return res.json(project);
}
@Get("id/:id/memberships")
@Middleware([isAuthenticated])
public async getProjectMembershipsByID(req: Request, res: Response) {
const { id: projectId } = UtilitySchemas.id.parse(req.params);
const { userId } = res.locals.auth as IJwt;
const project = await ProjectService.id(projectId);
if (!project) {
throw new NotFound("project");
}
const isMember = await MembershipService.isMember(projectId, userId);
if (!isMember) {
throw new NotAllowed();
}
const memberships = await ProjectService.memberships(projectId);
return res.status(200).json(memberships);
}
@Get("id/:id/usage")
@Middleware([isAuthenticated])
public async getProjectUsageByID(req: Request, res: Response) {
const { id: projectId } = UtilitySchemas.id.parse(req.params);
const { userId } = res.locals.auth as IJwt;
const project = await ProjectService.id(projectId);
if (!project) {
throw new NotFound("project");
}
const isMember = await MembershipService.isMember(projectId, userId);
if (!isMember) {
throw new NotAllowed();
}
const usage = await ProjectService.usage(projectId);
return res.status(200).json(usage);
}
@Get("id/:id/events")
@Middleware([isAuthenticated])
public async getProjectEventsByID(req: Request, res: Response) {
const { id: projectId } = UtilitySchemas.id.parse(req.params);
const { triggers } = z
.object({
triggers: z
.boolean()
.optional()
.default(true)
.or(z.string().transform((str) => str.toLowerCase() === "true")),
})
.parse(req.query);
const { userId } = res.locals.auth as IJwt;
const project = await ProjectService.id(projectId);
if (!project) {
throw new NotFound("project");
}
const isMember = await MembershipService.isMember(projectId, userId);
if (!isMember) {
throw new NotAllowed();
}
const events = await ProjectService.events(projectId, triggers);
return res.status(200).json(events);
}
@Get("id/:id/actions")
@Middleware([isAuthenticated])
public async getProjectActionsByID(req: Request, res: Response) {
const { id: projectId } = UtilitySchemas.id.parse(req.params);
const { userId } = res.locals.auth as IJwt;
const project = await ProjectService.id(projectId);
if (!project) {
throw new NotFound("project");
}
const isMember = await MembershipService.isMember(projectId, userId);
if (!isMember) {
throw new NotAllowed();
}
const actions = await ProjectService.actions(projectId);
return res.status(200).json(actions);
}
@Get("id/:id/templates")
@Middleware([isAuthenticated])
public async getProjectTemplatesByID(req: Request, res: Response) {
const { id: projectId } = UtilitySchemas.id.parse(req.params);
const { userId } = res.locals.auth as IJwt;
const project = await ProjectService.id(projectId);
if (!project) {
throw new NotFound("project");
}
const isMember = await MembershipService.isMember(projectId, userId);
if (!isMember) {
throw new NotAllowed();
}
const templates = await ProjectService.templates(projectId);
return res.status(200).json(templates);
}
@Get("id/:id/contacts/search")
@Middleware([isAuthenticated])
public async searchContacts(req: Request, res: Response) {
const { id: projectId } = UtilitySchemas.id.parse(req.params);
const { userId } = res.locals.auth as IJwt;
const project = await ProjectService.id(projectId);
if (!project) {
throw new NotFound("project");
}
const isMember = await MembershipService.isMember(projectId, userId);
if (!isMember) {
throw new NotAllowed();
}
const { query } = z.object({ query: z.string().min(1) }).parse(req.query);
const contacts = await prisma.contact.findMany({
where: {
projectId: project.id,
OR: [
{ email: { contains: query, mode: "insensitive" } },
{ data: { contains: query, mode: "insensitive" } },
],
},
select: {
id: true,
email: true,
subscribed: true,
createdAt: true,
triggers: { select: { createdAt: true } },
emails: { select: { createdAt: true } },
},
orderBy: [{ createdAt: "desc" }],
});
return res.status(200).json({
contacts,
count: contacts.length,
});
}
@Get("id/:id/contacts/count")
@Middleware([isAuthenticated])
public async getProjectContactCountByID(req: Request, res: Response) {
const { id: projectId } = UtilitySchemas.id.parse(req.params);
const { userId } = res.locals.auth as IJwt;
const project = await ProjectService.id(projectId);
if (!project) {
throw new NotFound("project");
}
const isMember = await MembershipService.isMember(projectId, userId);
if (!isMember) {
throw new NotAllowed();
}
const count = await ProjectService.contacts.count(projectId);
return res.status(200).json(count);
}
@Get("id/:id/contacts/metadata")
@Middleware([isAuthenticated])
public async getProjectMetadataByID(req: Request, res: Response) {
const { id: projectId } = UtilitySchemas.id.parse(req.params);
const { userId } = res.locals.auth as IJwt;
const project = await ProjectService.id(projectId);
if (!project) {
throw new NotFound("project");
}
const isMember = await MembershipService.isMember(projectId, userId);
if (!isMember) {
throw new NotAllowed();
}
const metadata = await ProjectService.metadata(projectId);
return res.status(200).json(metadata);
}
@Get("id/:id/contacts")
@Middleware([isAuthenticated])
public async getProjectContactsByID(req: Request, res: Response) {
const { id: projectId } = UtilitySchemas.id.parse(req.params);
const { page } = UtilitySchemas.pagination.parse(req.query);
const { userId } = res.locals.auth as IJwt;
const project = await ProjectService.id(projectId);
if (!project) {
throw new NotFound("project");
}
const isMember = await MembershipService.isMember(projectId, userId);
if (!isMember) {
throw new NotAllowed();
}
if (page === 0) {
const contacts = await ProjectService.contacts.get(projectId);
return res.status(200).json({ contacts, count: contacts?.length });
}
const contacts = await ProjectService.contacts.paginated(projectId, page);
const count = await ProjectService.contacts.count(projectId);
return res.status(200).json({ contacts, count });
}
@Get("id/:id/feed")
@Middleware([isAuthenticated])
public async getProjectFeedByID(req: Request, res: Response) {
const { id: projectId } = UtilitySchemas.id.parse(req.params);
const { page } = UtilitySchemas.pagination.parse(req.query);
const { userId } = res.locals.auth as IJwt;
const project = await ProjectService.id(projectId);
if (!project) {
throw new NotFound("project");
}
const isMember = await MembershipService.isMember(projectId, userId);
if (!isMember) {
throw new NotAllowed();
}
const feed = await ProjectService.feed(projectId, page);
return res.status(200).json(feed);
}
@Get("id/:id/campaigns")
@Middleware([isAuthenticated])
public async getProjectCampaignsByID(req: Request, res: Response) {
const { id: projectId } = UtilitySchemas.id.parse(req.params);
const { userId } = res.locals.auth as IJwt;
const project = await ProjectService.id(projectId);
if (!project) {
throw new NotFound("project");
}
const isMember = await MembershipService.isMember(projectId, userId);
if (!isMember) {
throw new NotAllowed();
}
const campaigns = await ProjectService.campaigns(projectId);
return res.status(200).json(campaigns);
}
@Get("id/:id/emails/count")
@Middleware([isAuthenticated])
public async getProjectEmailCountByID(req: Request, res: Response) {
const { id: projectId } = UtilitySchemas.id.parse(req.params);
const { userId } = res.locals.auth as IJwt;
const project = await ProjectService.id(projectId);
if (!project) {
throw new NotFound("project");
}
const isMember = await MembershipService.isMember(projectId, userId);
if (!isMember) {
throw new NotAllowed();
}
const count = await ProjectService.emails.count(projectId);
return res.status(200).json(count);
}
@Get("id/:id/emails")
@Middleware([isAuthenticated])
public async getProjectEmailsByID(req: Request, res: Response) {
const { id: projectId } = UtilitySchemas.id.parse(req.params);
const { userId } = res.locals.auth as IJwt;
const project = await ProjectService.id(projectId);
if (!project) {
throw new NotFound("project");
}
const isMember = await MembershipService.isMember(projectId, userId);
if (!isMember) {
throw new NotAllowed();
}
const emails = await ProjectService.emails.get(projectId);
return res.status(200).json(emails);
}
@Get("id/:id/analytics")
@Middleware([isAuthenticated])
public async getProjectAnalyticsByID(req: Request, res: Response) {
const { id: projectId } = UtilitySchemas.id.parse(req.params);
const { method } = ProjectSchemas.analytics.parse(req.query);
const { userId } = res.locals.auth as IJwt;
const project = await ProjectService.id(projectId);
if (!project) {
throw new NotFound("project");
}
const isMember = await MembershipService.isMember(projectId, userId);
if (!isMember) {
throw new NotAllowed();
}
const analytics = await ProjectService.analytics({ id: projectId, method });
return res.status(200).json(analytics);
}
@Post("create")
@Middleware([isAuthenticated])
public async createProject(req: Request, res: Response) {
const { name, url } = ProjectSchemas.create.parse(req.body);
const { userId } = res.locals.auth as IJwt;
const user = await UserService.id(userId);
if (!user) {
throw new NotAuthenticated();
}
let secretKey = "";
let secretIsAvailable = false;
let publicKey = "";
let publicIsAvailable = false;
while (!secretIsAvailable) {
secretKey = generateToken("secret");
secretIsAvailable = await ProjectService.secretIsAvailable(secretKey);
}
while (!publicIsAvailable) {
publicKey = generateToken("public");
publicIsAvailable = await ProjectService.publicIsAvailable(publicKey);
}
const project = await prisma.project.create({
data: {
name,
url,
secret: secretKey,
public: publicKey,
memberships: {
create: [{ userId, role: "OWNER" }],
},
},
});
await redis.del(Keys.User.projects(userId));
await redis.del(Keys.Project.secret(project.secret));
await redis.del(Keys.Project.public(project.public));
await redis.del(Keys.Project.id(project.id));
return res.status(200).json({ success: true, data: project });
}
@Post("id/:id/regenerate")
@Middleware([isAuthenticated])
public async regenerateAPIkey(req: Request, res: Response) {
const { userId } = res.locals.auth as IJwt;
let project = await ProjectService.id(req.params.id);
if (!project) {
throw new NotFound("project");
}
const user = await UserService.id(userId);
if (!user) {
throw new NotAuthenticated();
}
const isAdmin = await MembershipService.isAdmin(project.id, userId);
if (!isAdmin) {
throw new NotAllowed();
}
let secretKey = "";
let secretIsAvailable = false;
let publicKey = "";
let publicIsAvailable = false;
while (!secretIsAvailable) {
secretKey = generateToken("secret");
secretIsAvailable = await ProjectService.secretIsAvailable(secretKey);
}
while (!publicIsAvailable) {
publicKey = generateToken("public");
publicIsAvailable = await ProjectService.secretIsAvailable(publicKey);
}
project = await prisma.project.update({
where: { id: project.id },
data: { secret: secretKey, public: publicKey },
});
await redis.del(Keys.User.projects(userId));
return res.status(200).json({ success: true, project });
}
@Put("update")
@Middleware([isAuthenticated])
public async updateProject(req: Request, res: Response) {
const { id: projectId, name, url } = ProjectSchemas.update.parse(req.body);
const { userId } = res.locals.auth as IJwt;
let project = await ProjectService.id(projectId);
if (!project) {
throw new NotFound("project");
}
const isAdmin = await MembershipService.isAdmin(projectId, userId);
if (!isAdmin) {
throw new NotAllowed();
}
project = await prisma.project.update({
where: { id: projectId },
data: {
name,
url,
},
});
await redis.del(Keys.Project.id(project.id));
await redis.del(Keys.Project.secret(project.secret));
await redis.del(Keys.User.projects(userId));
return res.status(200).json({ success: true, data: project });
}
@Put("update/identity")
@Middleware([isAuthenticated])
public async updateIdentity(req: Request, res: Response) {
const { id: projectId, from } = IdentitySchemas.update.parse(req.body);
const { userId } = res.locals.auth as IJwt;
let project = await ProjectService.id(projectId);
if (!project) {
throw new NotFound("project");
}
const isAdmin = await MembershipService.isAdmin(projectId, userId);
if (!isAdmin) {
throw new NotAllowed();
}
project = await prisma.project.update({
where: { id: projectId },
data: {
from,
},
});
await redis.del(Keys.Project.id(project.id));
await redis.del(Keys.Project.secret(project.secret));
await redis.del(Keys.User.projects(userId));
return res.status(200).json({ success: true, data: project });
}
@Delete("delete")
@Middleware([isAuthenticated])
public async deleteProject(req: Request, res: Response) {
const { id: projectId } = UtilitySchemas.id.parse(req.body);
const { userId } = res.locals.auth as IJwt;
const project = await ProjectService.id(projectId);
if (!project) {
throw new NotFound("project");
}
const user = await UserService.id(userId);
if (!user) {
throw new NotAuthenticated();
}
const isOwner = await MembershipService.isOwner(projectId, userId);
if (!isOwner) {
throw new NotAllowed();
}
await prisma.project.delete({ where: { id: project.id } });
await redis.del(Keys.User.projects(userId));
await redis.del(Keys.Project.id(projectId));
return res.status(200).json({ success: true, data: project });
}
}
+140
View File
@@ -0,0 +1,140 @@
import { Controller, Post } from "@overnightjs/core";
import type { Request, Response } from "express";
import signale from "signale";
import { prisma } from "../database/prisma";
import { ContactService } from "../services/ContactService";
import { EmailService } from "../services/EmailService";
import { ProjectService } from "../services/ProjectService";
@Controller("tasks")
export class Tasks {
@Post()
public async handleTasks(req: Request, res: Response) {
// Get all tasks with a runBy data in the past
const tasks = await prisma.task.findMany({
where: { runBy: { lte: new Date() } },
orderBy: { runBy: "asc" },
include: {
action: { include: { template: true, notevents: true } },
campaign: true,
contact: true,
},
});
for (const task of tasks) {
const { action, campaign, contact } = task;
const project = await ProjectService.id(contact.projectId);
// If the project does not exist or is disabled, delete all tasks
if (!project) {
await prisma.task.deleteMany({
where: {
contact: {
projectId: contact.projectId,
},
},
});
continue;
}
let subject = "";
let body = "";
if (action) {
const { template, notevents } = action;
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,
),
)
) {
await prisma.task.delete({ where: { id: task.id } });
continue;
}
}
({ subject, body } = EmailService.format({
subject: template.subject,
body: template.body,
data: {
plunk_id: contact.id,
plunk_email: contact.email,
...JSON.parse(contact.data ?? "{}"),
},
}));
} else if (campaign) {
({ subject, body } = EmailService.format({
subject: campaign.subject,
body: campaign.body,
data: {
plunk_id: contact.id,
plunk_email: contact.email,
...JSON.parse(contact.data ?? "{}"),
},
}));
}
const { messageId } = await EmailService.send({
from: {
name: project.from ?? project.name,
email:
project.verified && project.email
? project.email
: "[email protected]",
},
to: [contact.email],
content: {
subject,
html: EmailService.compile({
content: body,
footer: {
unsubscribe: campaign
? true
: !!action && action.template.type === "MARKETING",
},
contact: {
id: contact.id,
},
project: {
name: project.name,
},
isHtml:
(campaign && campaign.style === "HTML") ??
(!!action && action.template.style === "HTML"),
}),
},
});
const emailData: {
messageId: string;
contactId: string;
actionId?: string;
campaignId?: string;
} = {
messageId,
contactId: contact.id,
};
if (action) {
emailData.actionId = action.id;
} else if (campaign) {
emailData.campaignId = campaign.id;
}
await prisma.email.create({ data: emailData });
await prisma.task.delete({ where: { id: task.id } });
signale.success(
`Task completed for ${contact.email} from ${project.name}`,
);
}
return res.status(200).json({ success: true });
}
}
+32
View File
@@ -0,0 +1,32 @@
import { Controller, Get, Middleware } from "@overnightjs/core";
import type { Request, Response } from "express";
import { NotAuthenticated } from "../exceptions";
import { type IJwt, isAuthenticated } from "../middleware/auth";
import { UserService } from "../services/UserService";
@Controller("users")
export class Users {
@Get("@me")
@Middleware([isAuthenticated])
public async me(req: Request, res: Response) {
const auth = res.locals.auth as IJwt;
const me = await UserService.id(auth.userId);
if (!me) {
throw new NotAuthenticated();
}
return res.status(200).json({ id: me.id, email: me.email });
}
@Get("@me/projects")
@Middleware([isAuthenticated])
public async meProjects(req: Request, res: Response) {
const auth = res.locals.auth as IJwt;
const projects = await UserService.projects(auth.userId);
return res.status(200).json(projects);
}
}
@@ -0,0 +1,191 @@
import { Controller, Post } from "@overnightjs/core";
import type { Event } from "@prisma/client";
import type { Request, Response } from "express";
import signale from "signale";
import { prisma } from "../../../database/prisma";
import { ActionService } from "../../../services/ActionService";
import { ProjectService } from "../../../services/ProjectService";
const eventMap = {
Bounce: "BOUNCED",
Delivery: "DELIVERED",
Open: "OPENED",
Complaint: "COMPLAINT",
Click: "CLICKED",
} as const;
@Controller("sns")
export class SNSWebhook {
@Post()
public async receiveSNSWebhook(req: Request, res: Response) {
try {
const body = JSON.parse(req.body.Message);
const email = await prisma.email.findUnique({
where: { messageId: body.mail.messageId },
include: {
contact: true,
action: { include: { template: { include: { events: true } } } },
campaign: { include: { events: true } },
},
});
if (!email) {
return res.status(200).json({});
}
const project = await ProjectService.id(email.contact.projectId);
if (!project) {
return res.status(200).json({ success: false });
}
// The email was a transactional email
if (email.projectId) {
if (body.eventType === "Click") {
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}`,
);
}
if (body.eventType === "Bounce") {
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"
],
},
});
return res.status(200).json({ success: true });
}
if (body.eventType === "Complaint" || body.eventType === "Bounce") {
signale.warn(
`${body.eventType === "Complaint" ? "Complaint" : "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" | "Complaint"] },
});
await prisma.contact.update({
where: { id: email.contactId },
data: { subscribed: false },
});
return res.status(200).json({ success: true });
}
if (body.eventType === "Click") {
signale.success(
`Click received for ${email.contact.email} from ${project.name}`,
);
await prisma.click.create({
data: { emailId: email.id, link: body.click.link },
});
return res.status(200).json({ success: true });
}
let event: Event | undefined;
if (email.action) {
event = email.action.template.events.find((e) =>
e.name.includes(
(body.eventType as
| "Bounce"
| "Delivery"
| "Open"
| "Complaint"
| "Click") === "Delivery"
? "delivered"
: "opened",
),
);
}
if (email.campaign) {
event = email.campaign.events.find((e) =>
e.name.includes(
(body.eventType as
| "Bounce"
| "Delivery"
| "Open"
| "Complaint"
| "Click") === "Delivery"
? "delivered"
: "opened",
),
);
}
if (!event) {
return res.status(200).json({ success: false });
}
switch (body.eventType as "Delivery" | "Open") {
case "Delivery":
signale.success(
`Delivery received for ${email.contact.email} from ${project.name}`,
);
await prisma.email.update({
where: { messageId: body.mail.messageId },
data: { status: "DELIVERED" },
});
await prisma.trigger.create({
data: { contactId: email.contactId, eventId: event.id },
});
break;
case "Open":
signale.success(
`Open received for ${email.contact.email} from ${project.name}`,
);
await prisma.email.update({
where: { messageId: body.mail.messageId },
data: { status: "OPENED" },
});
await prisma.trigger.create({
data: { contactId: email.contactId, eventId: event.id },
});
break;
}
if (email.action) {
void ActionService.trigger({ event, contact: email.contact, project });
}
} catch (e) {
if (req.body.SubscribeURL) {
signale.info("--------------");
signale.info("SNS Topic Confirmation URL:");
signale.info(req.body.SubscribeURL);
signale.info("--------------");
} else {
signale.error(e);
}
}
return res.status(200).json({ success: true });
}
}
@@ -0,0 +1,6 @@
import {ChildControllers, Controller} from '@overnightjs/core';
import {SNSWebhook} from './SNS';
@Controller('incoming')
@ChildControllers([new SNSWebhook()])
export class IncomingWebhooks {}
@@ -0,0 +1,6 @@
import {ChildControllers, Controller} from '@overnightjs/core';
import {IncomingWebhooks} from './Incoming';
@Controller('webhooks')
@ChildControllers([new IncomingWebhooks()])
export class Webhooks {}
+270
View File
@@ -0,0 +1,270 @@
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 { ActionService } from "../../services/ActionService";
import { EventService } from "../../services/EventService";
import { MembershipService } from "../../services/MembershipService";
import { ProjectService } from "../../services/ProjectService";
import { TemplateService } from "../../services/TemplateService";
import { Keys } from "../../services/keys";
import { redis } from "../../services/redis";
@Controller("actions")
export class Actions {
@Get(":id")
@Middleware([isAuthenticated])
public async getActionById(req: Request, res: Response) {
const { id } = UtilitySchemas.id.parse(req.params);
const { userId } = res.locals.auth as IJwt;
const action = await ActionService.id(id);
if (!action) {
throw new NotFound("action");
}
const isMember = await MembershipService.isMember(action.projectId, userId);
if (!isMember) {
throw new NotFound("action");
}
return res.status(200).json(action);
}
@Get(":id/related")
@Middleware([isAuthenticated])
public async getRelatedActionsById(req: Request, res: Response) {
const { id } = UtilitySchemas.id.parse(req.params);
const { userId } = res.locals.auth as IJwt;
const action = await ActionService.id(id);
if (!action) {
throw new NotFound("action");
}
const isMember = await MembershipService.isMember(action.projectId, userId);
if (!isMember) {
throw new NotFound("action");
}
const related = await ActionService.related(id);
return res.status(200).json(related);
}
@Post()
@Middleware([isValidSecretKey])
public async createAction(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
const {
name,
runOnce,
delay,
template: templateId,
events,
notevents,
} = ActionSchemas.create.parse(req.body);
const template = await TemplateService.id(templateId);
if (!template) {
throw new NotFound("template");
}
const action = await prisma.action.create({
data: {
projectId: project.id,
name,
runOnce,
delay,
templateId: template.id,
},
});
await Promise.all([
events.map(async (e: string) => {
const event = await EventService.id(e);
if (!event) {
throw new NotFound("event");
}
if (event.projectId !== project.id) {
throw new NotFound("event");
}
await prisma.action.update({
where: { id: action.id },
data: { events: { connect: { id: event.id } } },
});
}),
notevents.map(async (e: string) => {
const event = await EventService.id(e);
if (!event) {
throw new NotFound("event");
}
if (event.projectId !== project.id) {
throw new NotFound("event");
}
await prisma.action.update({
where: { id: action.id },
data: { notevents: { connect: { id: event.id } } },
});
}),
]);
await redis.del(Keys.Action.id(action.id));
await redis.del(Keys.Project.actions(project.id));
return res.status(200).json(action);
}
@Put()
@Middleware([isValidSecretKey])
public async updateAction(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
const {
id,
template: templateId,
events,
notevents,
name,
runOnce,
delay,
} = ActionSchemas.update.parse(req.body);
let action = await ActionService.id(id);
if (!action || action.projectId !== project.id) {
throw new NotFound("action");
}
const template = await TemplateService.id(templateId);
if (!template || template.projectId !== project.id) {
throw new NotFound("template");
}
const actionEvents = await prisma.action.findUnique({
where: { id },
include: { events: true, notevents: true },
});
action = await prisma.action.update({
where: { id },
data: {
name,
runOnce,
delay,
templateId,
events: { disconnect: actionEvents?.events.map((e) => ({ id: e.id })) },
notevents: {
disconnect: actionEvents?.notevents.map((e) => ({ id: e.id })),
},
},
include: {
events: true,
notevents: true,
triggers: true,
emails: true,
template: true,
},
});
await Promise.all([
events.map(async (e: string) => {
const event = await EventService.id(e);
if (!event || event.projectId !== project.id) {
throw new NotFound("event");
}
await prisma.action.update({
where: { id },
data: { events: { connect: { id: event.id } } },
});
}),
notevents.map(async (e: string) => {
const event = await EventService.id(e);
if (!event || event.projectId !== project.id) {
throw new NotFound("event");
}
await prisma.action.update({
where: { id },
data: { notevents: { connect: { id: event.id } } },
});
}),
]);
await redis.del(Keys.Action.id(action.id));
await redis.del(Keys.Project.actions(project.id));
return res.status(200).json(action);
}
@Delete()
@Middleware([isValidSecretKey])
public async deleteAction(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
const { id } = UtilitySchemas.id.parse(req.body);
const action = await ActionService.id(id);
if (!action || action.projectId !== project.id) {
throw new NotFound("action");
}
await prisma.action.delete({ where: { id } });
await redis.del(Keys.Action.id(action.id));
await redis.del(Keys.Project.actions(project.id));
return res.status(200).json(action);
}
}
@@ -0,0 +1,347 @@
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 { CampaignService } from "../../services/CampaignService";
import { EmailService } from "../../services/EmailService";
import { MembershipService } from "../../services/MembershipService";
import { ProjectService } from "../../services/ProjectService";
import { Keys } from "../../services/keys";
import { redis } from "../../services/redis";
@Controller("campaigns")
export class Campaigns {
@Get(":id")
@Middleware([isAuthenticated])
public async getCampaignById(req: Request, res: Response) {
const { id } = UtilitySchemas.id.parse(req.params);
const { userId } = res.locals.auth as IJwt;
const campaign = await CampaignService.id(id);
if (!campaign) {
throw new NotFound("campaign");
}
const isMember = await MembershipService.isMember(
campaign.projectId,
userId,
);
if (!isMember) {
throw new NotFound("campaign");
}
return res.status(200).json(campaign);
}
@Post("send")
@Middleware([isValidSecretKey])
public async sendCampaign(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
const { id, live, delay: userDelay } = CampaignSchemas.send.parse(req.body);
const campaign = await CampaignService.id(id);
if (!campaign || campaign.projectId !== project.id) {
throw new NotFound("campaign");
}
if (live) {
if (campaign.recipients.length === 0) {
throw new HttpException(400, "No recipients found");
}
await prisma.campaign.update({
where: { id: campaign.id },
data: { status: "DELIVERED", delivered: new Date() },
});
await prisma.event.createMany({
data: [
{
projectId: project.id,
name: `${campaign.subject
.toLowerCase()
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
.replace(/ /g, "-")}-campaign-delivered`,
campaignId: campaign.id,
},
{
projectId: project.id,
name: `${campaign.subject
.toLowerCase()
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
.replace(/ /g, "-")}-campaign-opened`,
campaignId: campaign.id,
},
],
});
let delay = userDelay ?? 0;
const tasks = campaign.recipients.map((r, index) => {
if (index % 80 === 0) {
delay += 1;
}
return {
campaignId: campaign.id,
contactId: r.id,
runBy: dayjs().add(delay, "minutes").toDate(),
};
});
await prisma.task.createMany({ data: tasks });
} else {
const members = await ProjectService.memberships(project.id);
await EmailService.send({
from: {
name: project.from ?? project.name,
email:
project.verified && project.email
? project.email
: "[email protected]",
},
to: members.map((m) => m.email),
content: {
subject: `[Plunk Campaign Test] ${campaign.subject}`,
html: EmailService.compile({
content: campaign.body,
footer: {
unsubscribe: false,
},
contact: {
id: "",
},
project: {
name: project.name,
},
}),
},
});
}
await redis.del(Keys.Campaign.id(campaign.id));
await redis.del(Keys.Project.campaigns(project.id));
return res.status(200).json({});
}
@Post("duplicate")
@Middleware([isValidSecretKey])
public async duplicateCampaign(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
const { id } = UtilitySchemas.id.parse(req.body);
const campaign = await CampaignService.id(id);
if (!campaign) {
throw new NotFound("campaign");
}
const duplicatedCampaign = await prisma.campaign.create({
data: {
projectId: project.id,
subject: campaign.subject,
body: campaign.body,
style: campaign.style,
},
});
await redis.del(Keys.Campaign.id(campaign.id));
await redis.del(Keys.Project.campaigns(project.id));
return res.status(200).json(duplicatedCampaign);
}
@Post()
@Middleware([isValidSecretKey])
public async createCampaign(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
let { subject, body, recipients, style } = CampaignSchemas.create.parse(
req.body,
);
if (recipients.length === 1 && recipients[0] === "all") {
const projectContacts = await prisma.contact.findMany({
where: { projectId: project.id, subscribed: true },
select: { id: true },
});
recipients = projectContacts.map((c) => c.id);
}
const campaign = await prisma.campaign.create({
data: {
projectId: project.id,
subject,
body,
style,
},
});
const chunkSize = 500;
for (let i = 0; i < recipients.length; i += chunkSize) {
const chunk = recipients.slice(i, i + chunkSize);
await prisma.campaign.update({
where: { id: campaign.id },
data: {
recipients: {
connect: chunk.map((r: string) => ({ id: r })),
},
},
});
}
await redis.del(Keys.Campaign.id(campaign.id));
await redis.del(Keys.Project.campaigns(project.id));
return res.status(200).json(campaign);
}
@Put()
@Middleware([isValidSecretKey])
public async updateCampaign(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
// eslint-disable-next-line prefer-const
let { id, subject, body, recipients, style } = CampaignSchemas.update.parse(
req.body,
);
if (recipients.length === 1 && recipients[0] === "all") {
const projectContacts = await prisma.contact.findMany({
where: { projectId: project.id, subscribed: true },
select: { id: true },
});
recipients = projectContacts.map((c) => c.id);
}
let campaign = await CampaignService.id(id);
if (!campaign || campaign.projectId !== project.id) {
throw new NotFound("campaign");
}
campaign = await prisma.campaign.update({
where: { id },
data: {
subject,
body,
style,
},
include: {
recipients: { select: { id: true } },
emails: {
select: {
id: true,
status: true,
contact: { select: { id: true, email: true } },
},
},
},
});
await prisma.campaign.update({
where: { id },
data: {
recipients: {
set: [],
},
},
});
const chunkSize = 500;
for (let i = 0; i < recipients.length; i += chunkSize) {
const chunk = recipients.slice(i, i + chunkSize);
await prisma.campaign.update({
where: { id },
data: {
recipients: {
connect: chunk.map((r: string) => ({ id: r })),
},
},
});
}
await redis.del(Keys.Campaign.id(campaign.id));
await redis.del(Keys.Project.campaigns(project.id));
return res.status(200).json(campaign);
}
@Delete()
@Middleware([isValidSecretKey])
public async deleteCampaign(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
const { id } = UtilitySchemas.id.parse(req.body);
const campaign = await CampaignService.id(id);
if (!campaign || campaign.projectId !== project.id) {
throw new NotFound("campaign");
}
await prisma.campaign.delete({ where: { id } });
await redis.del(Keys.Campaign.id(campaign.id));
await redis.del(Keys.Project.campaigns(project.id));
return res.status(200).json(campaign);
}
}
+334
View File
@@ -0,0 +1,334 @@
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 { ActionService } from "../../services/ActionService";
import { ContactService } from "../../services/ContactService";
import { EventService } from "../../services/EventService";
import { ProjectService } from "../../services/ProjectService";
import { Keys } from "../../services/keys";
import { redis } from "../../services/redis";
@Controller("contacts")
export class Contacts {
@Get("count")
@Middleware([isValidKey])
public async getContactCount(req: Request, res: Response) {
const { key } = res.locals.auth as IKey;
const project = await ProjectService.key(key);
if (!project) {
throw new NotFound("project");
}
const count = await ProjectService.contacts.count(project.id);
return res.status(200).json({ count });
}
@Get(":id")
public async getContactById(req: Request, res: Response) {
const { id } = UtilitySchemas.id.parse(req.params);
const { withProject } = z
.object({
withProject: z
.boolean()
.default(false)
.or(z.string().transform((s) => s === "true")),
})
.parse(req.query);
const contact = await ContactService.id(id);
if (!contact) {
throw new NotFound("contact");
}
if (withProject) {
const project = await ProjectService.id(contact.projectId);
if (!project) {
throw new NotFound("project");
}
return res.status(200).json({
...contact,
project: { name: project.name, public: project.public },
});
}
return res.status(200).json(contact);
}
@Get()
@Middleware([isValidSecretKey])
public async getContacts(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
const contacts = await ProjectService.contacts.get(project.id);
return res.status(200).json(
contacts?.map((c) => {
return {
id: c.id,
email: c.email,
subscribed: c.subscribed,
data: c.data,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
};
}),
);
}
@Post("unsubscribe")
@Middleware([isValidKey])
public async unsubscribe(req: Request, res: Response) {
const { key } = res.locals.auth as IKey;
const project = await ProjectService.key(key);
if (!project) {
throw new NotFound("project");
}
const { id } = UtilitySchemas.id.parse(req.body);
const contact = await ContactService.id(id);
if (!contact || contact.projectId !== project.id) {
throw new NotFound("contact");
}
await prisma.contact.update({
where: { id },
data: { subscribed: false },
});
let event = await EventService.event(project.id, "unsubscribe");
if (!event) {
event = await prisma.event.create({
data: { name: "unsubscribe", projectId: project.id },
});
await redis.del(Keys.Project.events(project.id, true));
await redis.del(Keys.Project.events(project.id, false));
await redis.del(Keys.Event.event(project.id, event.name));
await redis.del(Keys.Event.id(event.id));
}
await prisma.trigger.create({
data: { eventId: event.id, contactId: contact.id },
});
await redis.del(Keys.Contact.id(contact.id));
await ActionService.trigger({ event, contact, project });
await redis.del(Keys.Project.contacts(project.id));
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 });
}
@Post("subscribe")
@Middleware([isValidKey])
public async subscribe(req: Request, res: Response) {
const { key } = res.locals.auth as IKey;
const project = await ProjectService.key(key);
if (!project) {
throw new NotFound("project");
}
const { id } = UtilitySchemas.id.parse(req.body);
const contact = await ContactService.id(id);
if (!contact || contact.projectId !== project.id) {
throw new NotFound("contact");
}
await prisma.contact.update({
where: { id },
data: { subscribed: true },
});
let event = await EventService.event(project.id, "subscribe");
if (!event) {
event = await prisma.event.create({
data: { name: "subscribe", projectId: project.id },
});
await redis.del(Keys.Project.events(project.id, true));
await redis.del(Keys.Project.events(project.id, false));
await redis.del(Keys.Event.event(project.id, event.name));
await redis.del(Keys.Event.id(event.id));
}
await prisma.trigger.create({
data: { eventId: event.id, contactId: contact.id },
});
await redis.del(Keys.Contact.id(contact.id));
await ActionService.trigger({ event, contact, project });
await redis.del(Keys.Project.contacts(project.id));
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 });
}
@Post()
@Middleware([isValidSecretKey])
public async createContact(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
const { email, subscribed, data } = ContactSchemas.create.parse(req.body);
let contact = await ContactService.email(project.id, email);
if (contact) {
throw new HttpException(409, "Contact already exists");
}
contact = await prisma.contact.create({
data: {
projectId: project.id,
email,
subscribed,
data: data ? JSON.stringify(data) : null,
},
});
await redis.del(Keys.Project.contacts(project.id));
await redis.del(Keys.Contact.id(contact.id));
await redis.del(Keys.Contact.email(project.id, email));
return res.status(200).json({
success: true,
id: contact.id,
email: contact.email,
subscribed: contact.subscribed,
data: contact.data,
createdAt: contact.createdAt,
updatedAt: contact.updatedAt,
});
}
@Put()
@Middleware([isValidSecretKey])
public async updateContact(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
const { id, email, subscribed, data } = ContactSchemas.update.parse(
req.body,
);
let contact = await ContactService.id(id);
if (!contact || contact.projectId !== project.id) {
throw new NotFound("contact");
}
contact = await prisma.contact.update({
where: { id },
data: { email, subscribed, data: data ? JSON.stringify(data) : null },
include: {
triggers: { include: { event: true, action: true } },
emails: { where: { subject: { not: null } } },
},
});
await redis.del(Keys.Project.contacts(project.id));
await redis.del(Keys.Contact.id(contact.id));
await redis.del(Keys.Contact.email(project.id, email));
return res.status(200).json({
success: true,
id: contact.id,
email: contact.email,
subscribed: contact.subscribed,
data: contact.data,
createdAt: contact.createdAt,
updatedAt: contact.updatedAt,
});
}
@Delete()
@Middleware([isValidSecretKey])
public async deleteContact(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
const { id } = UtilitySchemas.id.parse(req.body);
const contact = await ContactService.id(id);
if (!contact || contact.projectId !== project.id) {
throw new NotFound("contact");
}
await prisma.contact.delete({ where: { id } });
await redis.del(Keys.Project.contacts(project.id));
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,
id: contact.id,
email: contact.email,
subscribed: contact.subscribed,
data: contact.data,
createdAt: contact.createdAt,
updatedAt: contact.updatedAt,
});
}
}
+39
View File
@@ -0,0 +1,39 @@
import { Controller, Delete, Middleware } from "@overnightjs/core";
import { UtilitySchemas } from "@plunk/shared";
import type { Request, Response } from "express";
import { prisma } from "../../database/prisma";
import { NotFound } from "../../exceptions";
import { type ISecret, isValidSecretKey } from "../../middleware/auth";
import { EventService } from "../../services/EventService";
import { ProjectService } from "../../services/ProjectService";
import { Keys } from "../../services/keys";
import { redis } from "../../services/redis";
@Controller("events")
export class Events {
@Delete()
@Middleware([isValidSecretKey])
public async deleteEvent(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
const { id } = UtilitySchemas.id.parse(req.body);
const event = await EventService.id(id);
if (!event || event.projectId !== project.id) {
throw new NotFound("event");
}
await prisma.event.delete({ where: { id } });
await redis.del(Keys.Event.id(id));
return res.status(200).json(event);
}
}
@@ -0,0 +1,253 @@
import { randomBytes } from "node:crypto";
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 { MembershipService } from "../../services/MembershipService";
import { ProjectService } from "../../services/ProjectService";
import { TemplateService } from "../../services/TemplateService";
import { Keys } from "../../services/keys";
import { redis } from "../../services/redis";
@Controller("templates")
export class Templates {
@Get(":id")
@Middleware([isAuthenticated])
public async getTemplateById(req: Request, res: Response) {
const { id } = UtilitySchemas.id.parse(req.params);
const { userId } = res.locals.auth as IJwt;
const template = await TemplateService.id(id);
if (!template) {
throw new NotFound("template");
}
const isMember = await MembershipService.isMember(
template.projectId,
userId,
);
if (!isMember) {
throw new NotFound("template");
}
return res.status(200).json(template);
}
@Post("duplicate")
@Middleware([isValidSecretKey])
public async duplicateTemplate(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
const { id } = UtilitySchemas.id.parse(req.body);
const template = await TemplateService.id(id);
if (!template || template.projectId !== project.id) {
throw new NotFound("template");
}
const duplicatedTemplate = await prisma.template.create({
data: {
projectId: project.id,
subject: template.subject,
body: template.body,
type: template.type,
style: template.style,
},
});
await prisma.event.createMany({
data: [
{
projectId: project.id,
name: `${duplicatedTemplate.subject
.toLowerCase()
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
.replace(/ /g, "-")}-template-delivered`,
templateId: template.id,
},
{
projectId: project.id,
name: `${duplicatedTemplate.subject
.toLowerCase()
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
.replace(/ /g, "-")}-template-opened`,
templateId: template.id,
},
],
});
await redis.del(Keys.Project.templates(project.id));
await redis.del(Keys.Template.id(template.id));
return res.status(200).json(template);
}
@Post()
@Middleware([isValidSecretKey])
public async createTemplate(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
const { subject, body, type, style } = TemplateSchemas.create.parse(
req.body,
);
const template = await prisma.template.create({
data: {
projectId: project.id,
subject,
body,
type,
style,
},
});
await prisma.event.createMany({
data: [
{
projectId: project.id,
name: `${subject
.toLowerCase()
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
.replace(/ /g, "-")}-template-delivered`,
templateId: template.id,
},
{
projectId: project.id,
name: `${subject
.toLowerCase()
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
.replace(/ /g, "-")}-template-opened`,
templateId: template.id,
},
],
});
await redis.del(Keys.Project.templates(project.id));
await redis.del(Keys.Template.id(template.id));
return res.status(200).json(template);
}
@Put()
@Middleware([isValidSecretKey])
public async updateTemplate(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
const { id, subject, body, type, style } = TemplateSchemas.update.parse(
req.body,
);
let template = await TemplateService.id(id);
if (!template || template.projectId !== project.id) {
throw new NotFound("template");
}
template = await prisma.template.update({
where: { id },
data: { subject, body, type, style },
include: {
actions: true,
},
});
const events = await prisma.event.findMany({
where: { templateId: template.id },
});
await Promise.all(
events.map(async (e) => {
await prisma.event.update({
where: { id: e.id },
data: {
name: e.name.includes("delivered")
? `${subject
.toLowerCase()
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
.replace(/ /g, "-")}-template-delivered`
: `${subject
.toLowerCase()
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
.replace(/ /g, "-")}-template-opened`,
},
});
}),
);
await redis.del(Keys.Project.templates(project.id));
await redis.del(Keys.Template.id(template.id));
return res.status(200).json(template);
}
@Delete()
@Middleware([isValidSecretKey])
public async deleteTemplate(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
const { id } = UtilitySchemas.id.parse(req.body);
const template = await TemplateService.id(id);
if (!template || template.projectId !== project.id) {
throw new NotFound("template");
}
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.",
);
}
await prisma.template.delete({ where: { id } });
await redis.del(Keys.Project.templates(project.id));
await redis.del(Keys.Template.id(template.id));
return res.status(200).json(template);
}
}
+298
View File
@@ -0,0 +1,298 @@
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 { ActionService } from "../../services/ActionService";
import { ContactService } from "../../services/ContactService";
import { EmailService } from "../../services/EmailService";
import { EventService } from "../../services/EventService";
import { ProjectService } from "../../services/ProjectService";
import { Keys } from "../../services/keys";
import { redis } from "../../services/redis";
import { Actions } from "./Actions";
import { Campaigns } from "./Campaigns";
import { Contacts } from "./Contacts";
import { Events } from "./Events";
import { Templates } from "./Templates";
@Controller("v1")
@ChildControllers([
new Actions(),
new Templates(),
new Campaigns(),
new Contacts(),
new Events(),
])
export class V1 {
@Post()
@Post("track")
@Middleware([isValidKey])
public async postEvent(req: Request, res: Response) {
const { key } = res.locals.auth as IKey;
const project = await ProjectService.key(key);
if (!project) {
throw new HttpException(401, "Incorrect Bearer token specified");
}
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)}`,
);
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].message);
}
const { event: name, email, data, subscribed } = result.data;
if (name === "subscribe" || name === "unsubscribe") {
throw new NotAllowed("subscribe & unsubscribe are reserved event names.");
}
let event = await EventService.event(project.id, name);
if (!event) {
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.id(event.id), JSON.stringify(event));
redis.del(Keys.Project.events(project.id, true));
redis.del(Keys.Project.events(project.id, false));
}
let contact = await ContactService.email(project.id, email);
if (!contact) {
contact = await prisma.contact.create({
data: {
email,
subscribed: subscribed ?? true,
projectId: project.id,
},
});
redis.del(Keys.Contact.id(contact.id));
redis.del(Keys.Contact.email(project.id, contact.email));
} else {
if (subscribed && contact.subscribed !== subscribed) {
contact = await prisma.contact.update({
where: { id: contact.id },
data: { subscribed },
});
redis.del(Keys.Contact.id(contact.id));
redis.del(Keys.Contact.email(project.id, contact.email));
}
}
if (data) {
const givenUserData = Object.entries(data);
const userData = JSON.parse(contact.data ?? "{}");
const dataToUpdate = JSON.parse(contact.data ?? "{}");
givenUserData.forEach(([key, value]) => {
userData[key] = value.value;
if (value.persistent) {
dataToUpdate[key] = value.value;
}
});
contact.data = JSON.stringify(userData);
await prisma.contact.update({
where: { id: contact.id },
data: { data: JSON.stringify(dataToUpdate) },
});
}
await prisma.trigger.create({
data: { eventId: event.id, contactId: contact.id },
});
void ActionService.trigger({ event, contact, project });
signale.success(
`${project.name} triggered ${event.name} for ${contact.email}`,
);
return res.status(200).json({
success: true,
contact: contact.id,
event: event.id,
timestamp: dayjs().toISOString(),
});
}
@Post("send")
@Middleware([isValidSecretKey])
public async send(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new HttpException(401, "Incorrect Bearer token specified");
}
const result = EventSchemas.send.safeParse(req.body);
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].message);
}
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",
);
}
if (from && from.split("@")[1] !== project.email?.split("@")[1]) {
throw new HttpException(
401,
"Custom from address must be from a verified domain",
);
}
const emails: {
contact: {
id: string;
email: string;
};
email: string;
}[] = [];
for (const email of to) {
let contact = await ContactService.email(project.id, email);
if (!contact) {
contact = await prisma.contact.create({
data: {
email,
subscribed: subscribed ?? false,
projectId: project.id,
},
});
redis.del(Keys.Contact.id(contact.id));
redis.del(Keys.Contact.email(project.id, contact.email));
} else {
if (subscribed && contact.subscribed !== subscribed) {
await prisma.contact.update({
where: { id: contact.id },
data: { subscribed },
});
redis.set(
Keys.Contact.email(project.id, contact.email),
JSON.stringify({
...contact,
subscribed,
}),
);
redis.del(Keys.Contact.id(contact.id));
}
}
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: {
name: name ?? project.from ?? project.name,
email: from ?? project.email,
},
reply: reply ?? from ?? project.email,
to: [email],
headers,
content: {
subject: enrichedSubject,
html: EmailService.compile({
isHtml: true,
content: enrichedBody,
footer: {
unsubscribe: false,
},
contact: {
id: contact.id,
},
project: {
name: project.name,
},
}),
},
});
const createdEmail = await prisma.email.create({
data: {
messageId,
subject,
body: enrichedBody,
contactId: contact.id,
projectId: project.id,
},
});
emails.push({
contact: { id: contact.id, email: contact.email },
email: createdEmail.id,
});
}
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(", ")}`,
);
return res
.status(200)
.json({ success: true, emails, timestamp: dayjs().toISOString() });
}
}
+3
View File
@@ -0,0 +1,3 @@
import {PrismaClient} from '@prisma/client';
export const prisma = new PrismaClient();
+34
View File
@@ -0,0 +1,34 @@
export class HttpException extends Error {
public constructor(
public readonly code: number,
message: string,
) {
super(message);
}
}
export class NotFound extends HttpException {
/**
* Construct a new NotFound exception
* @param resource The type of resource that was not found
*/
public constructor(resource: string) {
super(404, `That ${resource.toLowerCase()} was not found`);
}
}
export class NotAllowed extends HttpException {
/**
* Construct a new NotAllowed exception
* @param msg
*/
public constructor(msg = 'You are not allowed to perform this action') {
super(403, msg);
}
}
export class NotAuthenticated extends HttpException {
public constructor() {
super(401, 'You need to be authenticated to do this');
}
}
+177
View File
@@ -0,0 +1,177 @@
import dayjs from "dayjs";
import type { NextFunction, Request, Response } from "express";
import jsonwebtoken from "jsonwebtoken";
import { JWT_SECRET } from "../app/constants";
import { HttpException, NotAuthenticated } from "../exceptions";
export interface IJwt {
type: "jwt";
userId: string;
}
export interface ISecret {
type: "secret";
sk: string;
}
export interface IKey {
type: "key";
key: string;
}
/**
* Middleware to check if this unsubscribe is authenticated on the dashboard
* @param req
* @param res
* @param next
*/
export const isAuthenticated = (
req: Request,
res: Response,
next: NextFunction,
) => {
res.locals.auth = { type: "jwt", userId: parseJwt(req) };
next();
};
/**
* Middleware to check if this request is signed with an API secret key
* @param req
* @param res
* @param next
*/
export const isValidSecretKey = (
req: Request,
res: Response,
next: NextFunction,
) => {
res.locals.auth = { type: "secret", sk: parseBearer(req, "secret") };
next();
};
export const isValidKey = (req: Request, res: Response, next: NextFunction) => {
res.locals.auth = { type: "key", key: parseBearer(req) };
next();
};
export const jwt = {
/**
* Extracts a unsubscribe id from a jwt
* @param token The JWT token
*/
verify(token: string): string | null {
try {
const verified = jsonwebtoken.verify(token, JWT_SECRET) as {
id: string;
};
return verified.id;
} catch (e) {
return null;
}
},
/**
* Signs a JWT token
* @param id The unsubscribe's ID to sign into a jwt token
*/
sign(id: string): string {
return jsonwebtoken.sign({ id }, JWT_SECRET, {
expiresIn: "168h",
});
},
/**
* Find out when a JWT expires
* @param token The unsubscribe's jwt token
*/
expires(token: string): dayjs.Dayjs {
const { exp } = jsonwebtoken.verify(token, JWT_SECRET) as {
exp?: number;
};
return dayjs(exp);
},
};
/**
* Parse a unsubscribe's ID from the request JWT token
* @param request The express request object
*/
export function parseJwt(request: Request): string {
const token: string | undefined = request.cookies.token;
if (!token) {
throw new NotAuthenticated();
}
const id = jwt.verify(token);
if (!id) {
throw new NotAuthenticated();
}
return id;
}
/**
* Parse a bearer token from the request headers
* @param request The express request object
* @param type
*/
export function parseBearer(
request: Request,
type?: "secret" | "public",
): string {
const bearer: string | undefined = request.headers.authorization;
if (!bearer) {
throw new HttpException(401, "No authorization header passed");
}
if (!bearer.includes("Bearer")) {
throw new HttpException(401, "Please add Bearer in front of your API key");
}
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_...",
);
}
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_",
);
}
if (!type) {
return split[1];
}
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",
);
}
if (type === "secret" && !split[1].startsWith("sk_")) {
throw new HttpException(
401,
"Your secret key could not be parsed. Secret keys start with sk_ and should be passed in the authorization header as Bearer sk_...",
);
}
if (type === "public" && !split[1].startsWith("pk_")) {
throw new HttpException(
401,
"Your public key could not be parsed. Public keys start with pk_ and should be passed in the authorization header as Bearer sk_...",
);
}
return split[1];
}
+185
View File
@@ -0,0 +1,185 @@
import type { Contact, Event, Project } from "@prisma/client";
import dayjs from "dayjs";
import { prisma } from "../database/prisma";
import { ContactService } from "./ContactService";
import { EmailService } from "./EmailService";
import { Keys } from "./keys";
import { wrapRedis } from "./redis";
export class ActionService {
/**
* Gets an action by its ID
* @param id
*/
public static id(id: string) {
return wrapRedis(Keys.Action.id(id), async () => {
return prisma.action.findUnique({
where: { id },
include: {
events: true,
notevents: true,
triggers: true,
emails: true,
template: true,
},
});
});
}
/**
* Gets all actions that share an event with the action with the given ID
* @param id
*/
public static related(id: string) {
return wrapRedis(Keys.Action.related(id), async () => {
const action = await ActionService.id(id);
if (!action) {
return [];
}
return prisma.action.findMany({
where: {
events: { some: { id: { in: action.events.map((e) => e.id) } } },
id: { not: action.id },
},
include: { events: true },
});
});
}
/**
* Gets all actions that have an event as a trigger
* @param eventId
*/
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 },
});
});
}
/**
* Takes a contact and an event and triggers all required actions
* @param contact
* @param event
* @param 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,
);
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))
) {
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];
triggeredEvents = triggeredEvents.filter(
(e) => e.createdAt > lastActionTrigger.createdAt,
);
}
const updatedTriggers = [
...new Set(triggeredEvents.map((t) => t.eventId)),
];
const requiredTriggers = action.events.map((e) => e.id);
if (
updatedTriggers.sort().join(",") !== requiredTriggers.sort().join(",")
) {
// Not all required events have been triggered
continue;
}
await prisma.trigger.create({
data: { actionId: action.id, contactId: contact.id },
});
if (!contact.subscribed && action.template.type === "MARKETING") {
continue;
}
if (action.delay === 0) {
const { subject, body } = EmailService.format({
subject: action.template.subject,
body: action.template.body,
data: {
plunk_id: contact.id,
plunk_email: contact.email,
...JSON.parse(contact.data ?? "{}"),
},
});
const { messageId } = await EmailService.send({
from: {
name: project.from ?? project.name,
email:
project.verified && project.email
? project.email
: "[email protected]",
},
to: [contact.email],
content: {
subject,
html: EmailService.compile({
content: body,
footer: {
unsubscribe: action.template.type === "MARKETING",
},
contact: {
id: contact.id,
},
project: {
name: project.name,
},
isHtml: action.template.style === "HTML",
}),
},
});
await prisma.email.create({
data: {
messageId,
actionId: action.id,
contactId: contact.id,
},
});
} else {
await prisma.task.create({
data: {
actionId: action.id,
contactId: contact.id,
runBy: dayjs().add(action.delay, "minutes").toDate(),
},
});
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
import {prisma} from '../database/prisma';
import {verifyHash} from '../util/hash';
export class AuthService {
public static async verifyCredentials(email: string, password: string) {
const user = await prisma.user.findUnique({
where: {
email: email,
},
});
if (!user?.password) {
return false;
}
return await verifyHash(password, user.password);
}
}
@@ -0,0 +1,17 @@
import {Keys} from './keys';
import {wrapRedis} from './redis';
import {prisma} from '../database/prisma';
export class CampaignService {
public static id(id: string) {
return wrapRedis(Keys.Campaign.id(id), async () => {
return prisma.campaign.findUnique({
where: {id},
include: {
recipients: {select: {id: true}},
emails: {select: {id: true, status: true, contact: {select: {id: true, email: true}}}},
},
});
});
}
}
@@ -0,0 +1,24 @@
import {Keys} from './keys';
import {wrapRedis} from './redis';
import {prisma} from '../database/prisma';
export class ContactService {
public static id(id: string) {
return wrapRedis(Keys.Contact.id(id), async () => {
return prisma.contact.findUnique({
where: {id},
include: {triggers: {include: {event: true, action: true}}, emails: {where: {subject: {not: null}}}},
});
});
}
public static email(projectId: string, email: string) {
return wrapRedis(Keys.Contact.email(projectId, email), () => {
return prisma.contact.findFirst({where: {projectId, email}});
});
}
public static async triggers(id: string) {
return prisma.contact.findUniqueOrThrow({where: {id}}).triggers();
}
}
+531
View File
@@ -0,0 +1,531 @@
import mjml2html from "mjml";
import { APP_URI, AWS_SES_CONFIGURATION_SET } from "../app/constants";
import { ses } from "../util/ses";
export class EmailService {
public static async send({
from,
to,
content,
reply,
headers,
}: {
from: {
name: string;
email: string;
};
reply?: string;
to: string[];
content: {
subject: string;
html: string;
};
headers?: {
[key: string]: string;
} | null;
}) {
// Check if the body contains an unsubscribe link
const regex = /unsubscribe\/([a-f\d-]+)"/;
const containsUnsubscribeLink = content.html.match(regex);
let unsubscribeLink = "";
if (containsUnsubscribeLink?.[1]) {
const unsubscribeId = containsUnsubscribeLink[1];
unsubscribeLink = `List-Unsubscribe: <https://${APP_URI}/unsubscribe/${unsubscribeId}>`;
}
const rawMessage = `From: ${from.name} <${from.email}>
To: ${to.join(", ")}
Reply-To: ${reply || from.email}
Subject: ${content.subject}
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="NextPart"
${
headers
? Object.entries(headers)
.map(([key, value]) => `${key}: ${value}`)
.join("\n")
: ""
}
${unsubscribeLink}
--NextPart
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: 7bit
${EmailService.breakLongLines(content.html, 500)}
--NextPart--
`;
const response = await ses.sendRawEmail({
Destinations: to,
ConfigurationSetName: AWS_SES_CONFIGURATION_SET,
RawMessage: {
Data: new TextEncoder().encode(rawMessage),
},
Source: `${from.name} <${from.email}>`,
});
if (!response.MessageId) {
throw new Error("Could not send email");
}
return { messageId: response.MessageId };
}
public static compile({
content,
footer,
contact,
project,
isHtml,
}: {
content: string;
project: {
name: string;
};
contact: {
id: string;
};
footer: {
unsubscribe?: boolean;
};
isHtml?: boolean;
}) {
const html = content.replace(/<img/g, "<img");
if (isHtml) {
return `${html}
${
footer.unsubscribe
? ` <table align="center" width="100%" style="max-width: 480px; width: 100%; margin-left: auto; margin-right: auto; font-family: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; border: 0; cellpadding: 0; cellspacing: 0;" role="presentation">
<tbody>
<tr>
<td>
<hr style="border: none; border-top: 1px solid #eaeaea; width: 100%; margin-top: 12px; margin-bottom: 12px;">
<p style="font-size: 12px; line-height: 24px; margin: 16px 0; text-align: center; color: rgb(64, 64, 64);">
You received this email because you agreed to receive emails from ${project.name}. If you no longer wish to receive emails like this, please
<a href="https://${APP_URI}/unsubscribe/${contact.id}">update your preferences</a>.
</p>
</td>
</tr>
</tbody>
</table>`
: ""
}`;
}
return mjml2html(
`<mjml>
<mj-head>
<mj-font name="Inter" href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap" />
<mj-style inline="inline">
.prose {
color: #4a5568;
max-width: 600px;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
}
.prose [class~="lead"] {
color: #4a5568;
font-size: 20px;
line-height: 32px;
margin-top: 19px;
margin-bottom: 19px;
}
.prose a {
color: #1a202c;
text-decoration: underline;
}
.prose strong {
color: #1a202c;
font-weight: 600;
}
.prose ol {
counter-reset: list-counter;
margin-top: 20px;
margin-bottom: 20px;
}
.prose ol > li {
position: relative;
counter-increment: list-counter;
padding-left: 28px;
}
.prose ol > li::before {
content: counter(list-counter) ".";
position: absolute;
font-weight: 400;
color: #718096;
}
.prose ul > li {
position: relative;
padding-left: 28px;
}
.prose ul > li::before {
content: "";
position: absolute;
background-color: #cbd5e0;
border-radius: 50%;
width: 6px;
height: 6px;
top: 11px;
left: 4px;
}
.prose hr {
border-color: #e2e8f0;
border-top-width: 1px;
margin-top: 42px;
margin-bottom: 42px;
}
.prose blockquote {
font-weight: 500;
font-style: italic;
color: #1a202c;
border-left: 4px solid #e2e8f0;
quotes: initial;
margin-top: 25px;
margin-bottom: 25px;
padding-left: 16px;
}
.prose h1 {
color: #1a202c;
font-weight: 800;
font-size: 36px;
margin-top: 0px;
margin-bottom: 14px;
line-height: 40px;
}
.prose h2 {
color: #1a202c;
font-weight: 700;
font-size: 24px;
margin-top: 32px;
margin-bottom: 16px;
line-height: 32px;
}
.prose h3 {
color: #1a202c;
font-weight: 600;
font-size: 20px;
margin-top: 25px;
margin-bottom: 9.6px;
line-height: 32px;
}
.prose h4 {
color: #1a202c;
font-weight: 600;
margin-top: 24px;
margin-bottom: 8px;
line-height: 1.5;
}
.prose figure figcaption {
color: #718096;
font-size: 14px;
line-height: 1.4;
margin-top: 14px;
}
.prose code {
color: #1a202c;
font-weight: 600;
font-size: 14px;
}
.prose code::before {
content: "\`";
}
.prose code::after {
content: "\`";
}
.prose pre {
color: #e2e8f0;
background-color: #2d3748;
overflow-x: auto;
font-size: 14px;
line-height: 1.7142857;
margin-top: 27px;
margin-bottom: 27px;
border-radius: 6px;
padding-top: 13px;
padding-right: 18px;
padding-bottom: 13px;
padding-left: 18px;
}
.prose pre code {
background-color: transparent;
border-width: 0;
border-radius: 0;
padding: 0;
font-weight: 400;
color: inherit;
font-size: inherit;
font-family: inherit;
line-height: inherit;
}
.prose pre code::before {
content: "";
}
.prose pre code::after {
content: "";
}
.prose table {
width: 100%;
table-layout: auto;
margin-top: 32px;
margin-bottom: 32px;
font-size: 11px;
line-height: 1.7142857;
}
.prose thead {
color: #1a202c;
font-weight: 600;
border-bottom: 1px solid #cbd5e0;
}
.prose thead th {
vertical-align: bottom;
padding-right: 9px;
padding-bottom: 9px;
padding-left: 9px;
}
.prose tbody tr {
border-bottom: 1px solid #e2e8f0;
}
.prose tbody tr:last-child {
border-bottom-width: 0;
}
.prose tbody td {
vertical-align: top;
padding-top: 9px;
padding-right: 9px;
padding-bottom: 9px;
padding-left: 9px;
}
.prose {
font-size: 16px;
line-height: 1.75;
}
.prose p {
margin-top: 20px;
margin-bottom: 20px;
}
.prose img {
margin-top: 32px;
margin-bottom: 32px;
max-width: 100%;
height: auto;
display: block;
}
.prose video {
margin-top: 32px;
margin-bottom: 32px;
}
.prose figure {
margin-top: 32px;
margin-bottom: 32px;
}
.prose figure > * {
margin-top: 0;
margin-bottom: 0;
}
.prose h2 code {
font-size: 14px;
}
.prose h3 code {
font-size: 14px;
}
.prose ul {
margin-top: 20px;
margin-bottom: 20px;
}
.prose li {
margin-top: 8px;
margin-bottom: 8px;
}
.prose ol > li:before {
left: 0;
}
.prose > ul > li p {
margin-top: 12px;
margin-bottom: 12px;
}
.prose > ul > li > *:first-child {
margin-top: 20px;
}
.prose > ul > li > *:last-child {
margin-bottom: 20px;
}
.prose > ol > li > *:first-child {
margin-top: 20px;
}
.prose > ol > li > *:last-child {
margin-bottom: 20px;
}
.prose ul ul,
.prose ul ol,
.prose ol ul,
.prose ol ol {
margin-top: 12px;
margin-bottom: 12px;
}
.prose hr + * {
margin-top: 0;
}
.prose h2 + * {
margin-top: 0;
}
.prose h3 + * {
margin-top: 0;
}
.prose h4 + * {
margin-top: 0;
}
.prose thead th:first-child {
padding-left: 0;
}
.prose thead th:last-child {
padding-right: 0;
}
.prose tbody td:first-child {
padding-left: 0;
}
.prose tbody td:last-child {
padding-right: 0;
}
.prose > :first-child {
margin-top: 0;
}
.prose > :last-child {
margin-bottom: 0;
}
</mj-style>
</mj-head>
<mj-body>
<mj-section>
<mj-column>
<mj-raw>
<tr class="prose prose-neutral">
<td style="padding:10px 25px;word-break:break-word">
${html}
</td>
</tr>
</mj-raw>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
${
footer.unsubscribe
? `
<mj-divider border-width="2px" border-color="#f5f5f5"></mj-divider>
<mj-text align="center">
<p style="color: #a3a3a3; text-decoration: none; font-size: 12px; line-height: 1.7142857;">
You received this email because you agreed to receive emails from ${project.name}. If you no longer wish to receive emails like this, please <a style="text-decoration: underline" href="https://${APP_URI}/unsubscribe/${contact.id}" target="_blank">update your preferences</a>.
</p>
</mj-text>
`
: ""
}
</mj-column>
</mj-section>
</mj-body>
</mjml>`,
).html.replace(/^\s+|\s+$/g, "");
}
public static format({
subject,
body,
data,
}: { subject: string; body: string; data: Record<string, string> }) {
return {
subject: subject.replace(/\{\{(.*?)}}/g, (match, key) => {
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());
if (Array.isArray(data[mainKey])) {
return data[mainKey].map((e: string) => `<li>${e}</li>`).join("\n");
}
return data[mainKey] ?? defaultValue ?? "";
}),
};
}
private static breakLongLines(input: string, maxLineLength: number): string {
const lines = input.split("\n");
const result = [];
for (let line of lines) {
while (line.length > maxLineLength) {
let pos = maxLineLength;
while (pos > 0 && line[pos] !== " ") {
pos--;
}
if (pos === 0) {
pos = maxLineLength;
}
result.push(line.substring(0, pos));
line = line.substring(pos).trim();
}
result.push(line);
}
return result.join("\n");
}
}
+25
View File
@@ -0,0 +1,25 @@
import {prisma} from '../database/prisma';
import {REDIS_ONE_MINUTE, wrapRedis} from './redis';
import {Keys} from './keys';
export class EventService {
public static id(id: string) {
return wrapRedis(
Keys.Event.id(id),
() => {
return prisma.event.findUnique({where: {id}});
},
REDIS_ONE_MINUTE * 1440,
);
}
public static event(projectId: string, name: string) {
return wrapRedis(
Keys.Event.event(projectId, name),
() => {
return prisma.event.findFirst({where: {projectId, name}});
},
REDIS_ONE_MINUTE * 1440,
);
}
}
@@ -0,0 +1,76 @@
import type { Role } from "@prisma/client";
import { NODE_ENV } from "../app/constants";
import { prisma } from "../database/prisma";
import { Keys } from "./keys";
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;
}
const membership = await prisma.projectMembership.findFirst({
where: { projectId, userId },
});
return !!membership;
},
);
}
public static async isAdmin(projectId: string, userId: string) {
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"] } },
});
return !!membership;
},
);
}
public static async isOwner(projectId: string, userId: string) {
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" },
});
return !!membership;
},
);
}
public static async kick(projectId: string, userId: string) {
await prisma.projectMembership.delete({
where: { userId_projectId: { projectId, userId } },
});
await redis.del(Keys.Project.memberships(projectId));
await redis.del(Keys.User.projects(userId));
}
public static async invite(projectId: string, userId: string, role: Role) {
await prisma.projectMembership.create({
data: { projectId, userId, role },
});
await redis.del(Keys.Project.memberships(projectId));
await redis.del(Keys.User.projects(userId));
}
}
+468
View File
@@ -0,0 +1,468 @@
import dayjs from "dayjs";
import { prisma } from "../database/prisma";
import { Keys } from "./keys";
import { wrapRedis } from "./redis";
export class ProjectService {
public static contacts = {
get: (id: string) => {
return wrapRedis(Keys.Project.contacts(id), async () => {
return prisma.project.findUnique({ where: { id } }).contacts({
select: {
id: true,
email: true,
subscribed: true,
createdAt: true,
data: true,
updatedAt: true,
triggers: { select: { createdAt: true, eventId: true } },
},
});
});
},
paginated: (id: string, page: number) => {
return wrapRedis(Keys.Project.contacts(id, { page }), async () => {
return prisma.project.findUnique({ where: { id } }).contacts({
select: {
id: true,
email: true,
subscribed: true,
createdAt: true,
triggers: { select: { createdAt: true } },
emails: { select: { createdAt: true } },
},
orderBy: [{ createdAt: "desc" }],
take: 20,
skip: (page - 1) * 20,
});
});
},
count: (id: string) => {
return wrapRedis(Keys.Project.contacts(id, { count: true }), async () => {
return prisma.contact.count({ where: { projectId: id } });
});
},
};
public static emails = {
get: (id: string) => {
return wrapRedis(Keys.Project.emails(id), async () => {
return prisma.email.findMany({
where: {
OR: [
{ action: { projectId: id } },
{ campaign: { projectId: id } },
{ projectId: id },
],
},
orderBy: { createdAt: "desc" },
});
});
},
count: (id: string) => {
return wrapRedis(Keys.Project.emails(id, { count: true }), async () => {
return prisma.email.count({ where: { contact: { projectId: id } } });
});
},
};
public static id(id: string) {
return wrapRedis(Keys.Project.id(id), async () => {
return prisma.project.findUnique({ where: { id } });
});
}
public static key(key: string) {
if (key.startsWith("sk_")) {
return ProjectService.secret(key);
}
return ProjectService.public(key);
}
public static secret(secretKey: string) {
return wrapRedis(Keys.Project.secret(secretKey), () => {
return prisma.project.findUnique({ where: { secret: secretKey } });
});
}
public static public(publicKey: string) {
return wrapRedis(Keys.Project.public(publicKey), () => {
return prisma.project.findUnique({ where: { public: publicKey } });
});
}
public static async secretIsAvailable(secretKey: string) {
const project = await ProjectService.secret(secretKey);
return !project;
}
public static async publicIsAvailable(publicKey: string) {
const project = await ProjectService.public(publicKey);
return !project;
}
public static memberships(id: string) {
return wrapRedis(Keys.Project.memberships(id), async () => {
const memberships = await prisma.project
.findUnique({ where: { id } })
.memberships({ include: { user: true } });
if (!memberships) {
return [];
}
return memberships.map((membership) => {
return {
userId: membership.userId,
email: membership.user.email,
role: membership.role,
};
});
});
}
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,
},
},
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))),
),
];
});
}
public static async feed(id: string, page: number) {
const itemsPerPage = 10;
const skip = (page - 1) * itemsPerPage;
const triggers = await prisma.trigger.findMany({
where: { contact: { projectId: id } },
include: {
contact: {
select: {
id: true,
email: true,
},
},
event: {
select: {
name: true,
},
},
},
orderBy: { createdAt: "desc" },
});
const emails = await prisma.email.findMany({
where: { contact: { projectId: id } },
include: {
contact: {
select: {
id: true,
email: true,
},
},
},
orderBy: { createdAt: "desc" },
});
const combined = [...triggers, ...emails];
combined.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
return combined.slice(skip, skip + itemsPerPage);
}
public static usage(id: string) {
return wrapRedis(Keys.Project.usage(id), async () => {
const transactional = await prisma.email.count({
where: {
projectId: id,
createdAt: {
gte: new Date(dayjs().startOf("month").toISOString()),
lte: new Date(dayjs().endOf("month").toISOString()),
},
},
});
const automation = await prisma.email.count({
where: {
action: { projectId: id },
createdAt: {
gte: new Date(dayjs().startOf("month").toISOString()),
lte: new Date(dayjs().endOf("month").toISOString()),
},
},
});
const campaign = await prisma.email.count({
where: {
campaign: { projectId: id },
createdAt: {
gte: new Date(dayjs().startOf("month").toISOString()),
lte: new Date(dayjs().endOf("month").toISOString()),
},
},
});
return {
transactional,
automation,
campaign,
};
});
}
public static events(id: string, triggers: boolean) {
return wrapRedis(Keys.Project.events(id, triggers), async () => {
if (triggers) {
return prisma.project.findUnique({ where: { id } }).events({
include: {
triggers: {
select: { id: true, createdAt: true, contactId: true },
},
},
orderBy: { createdAt: "desc" },
});
}
return prisma.project.findUnique({ where: { id } }).events({
orderBy: { createdAt: "desc" },
});
});
}
public static actions(id: string) {
return wrapRedis(Keys.Project.actions(id), async () => {
return prisma.project.findUnique({ where: { id } }).actions({
include: {
triggers: { select: { id: true } },
template: true,
emails: { select: { id: true, status: true } },
tasks: { select: { id: true } },
},
});
});
}
public static templates(id: string) {
return wrapRedis(Keys.Project.templates(id), async () => {
return prisma.project.findUnique({ where: { id } }).templates({
include: { actions: true },
orderBy: { createdAt: "desc" },
});
});
}
public static campaigns(id: string) {
return wrapRedis(Keys.Project.campaigns(id), async () => {
return prisma.project.findUnique({ where: { id } }).campaigns({
include: {
recipients: { select: { id: true } },
emails: { select: { id: true, status: true } },
tasks: { select: { id: true } },
},
orderBy: { createdAt: "desc" },
});
});
}
public static analytics(params: {
id: string;
method?: "week" | "month" | "year";
}) {
return wrapRedis(Keys.Project.analytics(params.id), async () => {
const methods = {
week: {
daysBack: 7,
method: "week",
},
month: {
daysBack: 30,
method: "day",
},
year: {
daysBack: 365,
method: "month",
},
};
const end = dayjs().toDate();
const start = dayjs()
.subtract(methods[params.method ?? "week"].daysBack, "days")
.toDate();
const contacts = await prisma.$queryRaw`
WITH date_range AS (
SELECT generate_series(
(SELECT DATE_TRUNC('day', MIN("createdAt")) FROM contacts),
DATE_TRUNC('day', NOW()) + INTERVAL '1 day',
INTERVAL '1 day'
) AS day
)
SELECT
dr.day,
SUM(COALESCE(ct.count, 0)) OVER (ORDER BY dr.day) as count
FROM date_range dr
LEFT JOIN (
SELECT
DATE_TRUNC('day', c."createdAt") AS day,
COUNT(c.id) as count
FROM contacts c
WHERE "projectId" = ${params.id}
GROUP BY DATE_TRUNC('day', c."createdAt")
) ct ON dr.day = ct.day
WHERE dr.day < DATE_TRUNC('day', NOW())
ORDER BY dr.day DESC
LIMIT 30;
`;
const rawActionClicks = await prisma.$queryRaw`
SELECT clicks."link", a."name", count(clicks.id)::int FROM clicks
JOIN emails e on clicks."emailId" = e.id
JOIN actions a on e."actionId" = a.id
WHERE clicks."link" NOT LIKE '%unsubscribe%' AND DATE(clicks."createdAt") BETWEEN DATE(${start}) AND DATE(${end}) AND a."projectId" = ${params.id}
GROUP BY a."name", clicks."link"
`;
const combinedRoutes = {};
// @ts-expect-error
rawActionClicks.forEach((item) => {
const url = new URL(item.link);
const route = url.pathname;
// @ts-expect-error
if (combinedRoutes[route]) {
// @ts-expect-error
combinedRoutes[route].count += item.count;
} else {
// @ts-expect-error
combinedRoutes[route] = {
link: url.hostname + route,
name: item.name,
count: item.count,
};
}
});
const formattedActionClicks = Object.values(combinedRoutes).sort(
// @ts-expect-error
(a, b) => b.count - a.count,
);
const subscribed = await prisma.contact.count({
where: { subscribed: true, projectId: params.id },
});
const unsubscribed = await prisma.contact.count({
where: { subscribed: false, projectId: params.id },
});
const opened = await prisma.email.count({
where: {
contact: { projectId: params.id },
status: "OPENED",
},
});
const openedPrev = await prisma.email.count({
where: {
contact: { projectId: params.id },
status: "OPENED",
createdAt: {
lte: start,
},
},
});
const bounced = await prisma.email.count({
where: {
contact: { projectId: params.id },
status: "BOUNCED",
},
});
const bouncedPrev = await prisma.email.count({
where: {
contact: { projectId: params.id },
status: "BOUNCED",
createdAt: {
lte: start,
},
},
});
const complaint = await prisma.email.count({
where: {
contact: { projectId: params.id },
status: "COMPLAINT",
},
});
const complaintPrev = await prisma.email.count({
where: {
contact: { projectId: params.id },
status: "COMPLAINT",
createdAt: {
lte: start,
},
},
});
const total = await prisma.email.count({
where: {
contact: { projectId: params.id },
},
});
const totalPrev = await prisma.email.count({
where: {
contact: { projectId: params.id },
createdAt: {
lte: start,
},
},
});
return {
contacts: { timeseries: contacts, subscribed, unsubscribed },
emails: {
total,
opened,
bounced,
complaint,
totalPrev,
bouncedPrev,
complaintPrev,
openedPrev,
},
clicks: {
actions: formattedActionClicks,
},
};
});
}
}
@@ -0,0 +1,17 @@
import {wrapRedis} from './redis';
import {prisma} from '../database/prisma';
import {Keys} from './keys';
export class TemplateService {
public static id(id: string) {
return wrapRedis(Keys.Template.id(id), async () => {
return prisma.template.findUnique({where: {id}, include: {actions: true}});
});
}
public static actions(templateId: string) {
return wrapRedis(Keys.Template.actions(templateId), async () => {
return prisma.template.findUnique({where: {id: templateId}}).actions();
});
}
}
+61
View File
@@ -0,0 +1,61 @@
import dayjs from "dayjs";
import { NODE_ENV } from "../app/constants";
import { prisma } from "../database/prisma";
import { Keys } from "./keys";
import { wrapRedis } from "./redis";
export class UserService {
public static readonly COOKIE_NAME = "token";
public static id(id: string) {
return wrapRedis(Keys.User.id(id), () => {
return prisma.user.findUnique({
where: { id },
});
});
}
public static email(email: string) {
return wrapRedis(Keys.User.email(email), () => {
return prisma.user.findUnique({
where: { email },
});
});
}
public static async projects(id: string) {
return wrapRedis(Keys.User.projects(id), async () => {
const user = await prisma.user.findUnique({
where: { id },
include: { memberships: true },
});
if (!user) {
return [];
}
return prisma.project.findMany({
where: {
id: {
in: user.memberships.map((project) => project.projectId),
},
},
orderBy: { name: "asc" },
});
});
}
/**
* Generates cookie options
* @param expires An optional expiry for this cookie (useful for a logout)
*/
public static cookieOptions(expires?: Date) {
return {
httpOnly: true,
expires: expires ?? dayjs().add(168, "hours").toDate(),
secure: NODE_ENV !== "development",
sameSite: "lax",
path: "/",
} as const;
}
}
+140
View File
@@ -0,0 +1,140 @@
export const Keys = {
User: {
id(id: string): string {
return `account:id:${id}`;
},
email(email: string): string {
return `account:${email}`;
},
projects(id: string): string {
return `account:${id}:projects`;
},
},
Project: {
id(id: string): string {
return `project:id:${id}`;
},
secret(secretKey: string): string {
return `project:secret:${secretKey}`;
},
public(publicKey: string): string {
return `project:public:${publicKey}`;
},
memberships(id: string): string {
return `project:${id}:memberships`;
},
usage(id: string): string {
return `project:${id}:usage`;
},
events(id: string, triggers: boolean): string {
if (triggers) {
return `project:${id}:events:triggers`;
}
return `project:${id}:events`;
},
metadata(id: string): string {
return `project:${id}:metadata`;
},
actions(id: string): string {
return `project:${id}:actions`;
},
templates(id: string): string {
return `project:${id}:templates`;
},
feed(id: string): string {
return `project:${id}:feed`;
},
contacts(
id: string,
options?: {
page?: number;
count?: boolean;
},
): string {
if (options?.count) {
return `project:${id}:contacts:count`;
}
if (options?.page) {
return `project:${id}:contacts:page:${options.page}`;
}
return `project:${id}:contacts`;
},
campaigns(id: string): string {
return `project:${id}:campaigns`;
},
analytics(id: string): string {
return `project:${id}:analytics`;
},
emails(
id: string,
options?: {
count?: boolean;
},
): string {
if (options?.count) {
return `project:${id}:emails:count`;
}
return `project:${id}:emails`;
},
},
ProjectMembership: {
isMember(projectId: string, accountId: string) {
return `project:id:${projectId}:ismember:${accountId}`;
},
isAdmin(projectId: string, accountId: string) {
return `project:id:${projectId}:isadmin:${accountId}`;
},
isOwner(projectId: string, accountId: string) {
return `project:id:${projectId}:isowner:${accountId}`;
},
},
Campaign: {
id(id: string): string {
return `campaign:id:${id}`;
},
},
Template: {
id(id: string): string {
return `template:id:${id}`;
},
actions(templateId: string): string {
return `template:id:${templateId}:actions`;
},
},
Webhook: {
id(id: string): string {
return `webhook:id:${id}`;
},
},
Contact: {
id(id: string): string {
return `contact:id:${id}`;
},
email(projectId: string, email: string): string {
return `project:id:${projectId}:contact:email:${email}`;
},
},
Action: {
id(id: string): string {
return `action:id:${id}`;
},
related(id: string): string {
return `action:id:${id}:related`;
},
event(eventId: string): string {
return `action:event:id:${eventId}`;
},
},
Event: {
id(id: string): string {
return `event:id:${id}`;
},
event(projectId: string, name: string): string {
return `project:id:${projectId}:event:name:${name}`;
},
},
};
+27
View File
@@ -0,0 +1,27 @@
import Redis from 'ioredis';
import {REDIS_URL} from '../app/constants';
export const redis = new Redis(REDIS_URL);
export const REDIS_ONE_MINUTE = 60;
export const REDIS_DEFAULT_EXPIRY = REDIS_ONE_MINUTE / 60;
/**
* @param key The key for redis (use Keys#<type>)
* @param fn The function to return a resource. Can be a promise
* @param seconds The amount of seconds to hold this resource in redis for. Defaults to 60
*/
export async function wrapRedis<T>(key: string, fn: () => Promise<T>, seconds = REDIS_DEFAULT_EXPIRY): Promise<T> {
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached);
}
const recent = await fn();
if (recent) {
await redis.set(key, JSON.stringify(recent), 'EX', seconds);
}
return recent;
}
+33
View File
@@ -0,0 +1,33 @@
import bcrypt from "bcrypt";
/**
* Verifies a hash against a password
* @param {string} pass The password
* @param {string} hash The hash
*/
export const verifyHash = (pass: string, hash: string) => {
return new Promise((resolve, reject) => {
void bcrypt.compare(pass, hash, (err, res) => {
if (err) {
return reject(err);
}
return resolve(res);
});
});
};
/**
* Generates a hash from plain text
* @param {string} pass The password
* @returns {Promise<string>} Password hash
*/
export const createHash = (pass: string): Promise<string> => {
return new Promise((resolve, reject) => {
void bcrypt.hash(pass, 10, (err, res) => {
if (err) {
return reject(err);
}
resolve(res);
});
});
};
+53
View File
@@ -0,0 +1,53 @@
import { SES } from "@aws-sdk/client-ses";
import {
AWS_ACCESS_KEY_ID,
AWS_REGION,
AWS_SECRET_ACCESS_KEY,
} from "../app/constants";
export const ses = new SES({
apiVersion: "2010-12-01",
region: AWS_REGION,
credentials: {
accessKeyId: AWS_ACCESS_KEY_ID,
secretAccessKey: AWS_SECRET_ACCESS_KEY,
},
});
export const getIdentities = async (identities: string[]) => {
const res = await ses.getIdentityVerificationAttributes({
Identities: identities.flatMap((identity) => [identity.split("@")[1]]),
});
const parsedResult = Object.entries(res.VerificationAttributes ?? {});
return parsedResult.map((obj) => {
return { email: obj[0], status: obj[1].VerificationStatus };
});
};
export const verifyIdentity = async (email: string) => {
const DKIM = await ses.verifyDomainDkim({
Domain: email.includes("@") ? email.split("@")[1] : email,
});
await ses.setIdentityMailFromDomain({
Identity: email.includes("@") ? email.split("@")[1] : email,
MailFromDomain: `plunk.${email.includes("@") ? email.split("@")[1] : email}`,
});
return DKIM.DkimTokens;
};
export const getIdentityVerificationAttributes = async (email: string) => {
const attributes = await ses.getIdentityDkimAttributes({
Identities: [email, email.split("@")[1]],
});
const parsedAttributes = Object.entries(attributes.DkimAttributes ?? {});
return {
email: parsedAttributes[0][0],
tokens: parsedAttributes[0][1].DkimTokens,
status: parsedAttributes[0][1].DkimVerificationStatus,
};
};
+9
View File
@@ -0,0 +1,9 @@
import { randomBytes } from "node:crypto";
/**
* A function that generates a random 24 byte API secret
* @param type
*/
export function generateToken(type: "secret" | "public") {
return `${type === "secret" ? "sk" : "pk"}_${randomBytes(24).toString("hex")}`;
}