From a7caa912ecdb5828e8e0a0c07c99fbef91266071 Mon Sep 17 00:00:00 2001 From: Dan Klco Date: Mon, 8 Sep 2025 21:11:09 -0400 Subject: [PATCH] feat: add support for customizing the base template --- package.json | 3 +- packages/api/package.json | 1 + packages/api/src/controllers/Projects.ts | 48 +- packages/api/src/controllers/Tasks.ts | 5 + packages/api/src/controllers/v1/Campaigns.ts | 2 + packages/api/src/controllers/v1/index.ts | 5 +- packages/api/src/services/ActionService.ts | 4 + packages/api/src/services/EmailService.ts | 417 ++---------------- .../Navigation/SettingTabs/SettingTabs.tsx | 1 + .../src/pages/settings/base-template.tsx | 253 +++++++++++ .../dashboard/src/pages/settings/project.tsx | 5 +- packages/shared/src/index.ts | 12 +- packages/shared/src/templates.ts | 362 +++++++++++++++ .../migration.sql | 4 + prisma/schema.prisma | 4 + yarn.lock | 3 +- 16 files changed, 741 insertions(+), 388 deletions(-) create mode 100644 packages/dashboard/src/pages/settings/base-template.tsx create mode 100644 packages/shared/src/templates.ts create mode 100644 prisma/migrations/20250906095500_project_base_template/migration.sql diff --git a/package.json b/package.json index 7e4dbf65..997d7a23 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "migrate:deploy": "prisma migrate deploy", "generate": "prisma generate", "services:up": "docker compose -f docker-compose.dev.yml up -d", - "services:down": "docker compose -f docker-compose.dev.yml down" + "services:down": "docker compose -f docker-compose.dev.yml down", + "ses-local": "yarn dlx aws-ses-v2-local" }, "packageManager": "yarn@4.3.0" } diff --git a/packages/api/package.json b/packages/api/package.json index abfd4524..362ca3be 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -40,6 +40,7 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "express-async-errors": "^3.1.1", + "handlebars": "^4.7.8", "helmet": "^7.1.0", "ioredis": "^5.4.1", "jsonwebtoken": "^9.0.2", diff --git a/packages/api/src/controllers/Projects.ts b/packages/api/src/controllers/Projects.ts index caa27c50..e3c456ec 100644 --- a/packages/api/src/controllers/Projects.ts +++ b/packages/api/src/controllers/Projects.ts @@ -11,6 +11,7 @@ import { UserService } from "../services/UserService"; import { Keys } from "../services/keys"; import { redis } from "../services/redis"; import { generateToken } from "../util/tokens"; +import { EmailService } from "../services/EmailService"; @Controller("projects") export class Projects { @@ -450,6 +451,7 @@ export class Projects { memberships: { create: [{ userId, role: "OWNER" }], }, + templatingLanguage: "DEFAULT", }, }); @@ -512,10 +514,51 @@ export class Projects { return res.status(200).json({ success: true, project }); } + + @Post("preview/template") + @Middleware([isAuthenticated]) + public async previewProjectTemplate(req: Request, res: Response) { + const { id: projectId, baseTemplate, unsubscribeFooter } = 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(); + } + + try { + const html = EmailService.compile({ + content: "Hello, world!", + footer: { + unsubscribe: true, + }, + contact: { + id: "23", + }, + project: { + name: project.name, + baseTemplate: baseTemplate as string, + unsubscribeFooter: unsubscribeFooter as string, + }, + }); + return res.status(200).json({ success: true, html }); + } catch (error) { + return res.status(400).json({ success: false, message: (error as Error).message }); + } + } + @Put("update") @Middleware([isAuthenticated]) public async updateProject(req: Request, res: Response) { - const { id: projectId, name, url } = ProjectSchemas.update.parse(req.body); + const { id: projectId, name, url, baseTemplate, unsubscribeFooter, templatingLanguage } = ProjectSchemas.update.parse(req.body); const { userId } = res.locals.auth as IJwt; @@ -536,6 +579,9 @@ export class Projects { data: { name, url, + baseTemplate, + unsubscribeFooter, + templatingLanguage, }, }); diff --git a/packages/api/src/controllers/Tasks.ts b/packages/api/src/controllers/Tasks.ts index 4e003429..7b9285b1 100644 --- a/packages/api/src/controllers/Tasks.ts +++ b/packages/api/src/controllers/Tasks.ts @@ -5,6 +5,7 @@ import { prisma } from "../database/prisma"; import { ContactService } from "../services/ContactService"; import { EmailService } from "../services/EmailService"; import { ProjectService } from "../services/ProjectService"; +import { TemplatingLanguage } from "@plunk/shared"; @Controller("tasks") export class Tasks { @@ -66,6 +67,7 @@ export class Tasks { plunk_email: contact.email, ...JSON.parse(contact.data ?? "{}"), }, + templatingLanguage: project.templatingLanguage as TemplatingLanguage ?? "DEFAULT", })); } else if (campaign) { email = project.verified && project.email ? campaign.email ?? project.email : "no-reply@useplunk.dev"; @@ -79,6 +81,7 @@ export class Tasks { plunk_email: contact.email, ...JSON.parse(contact.data ?? "{}"), }, + templatingLanguage: project.templatingLanguage as TemplatingLanguage ?? "DEFAULT", })); } @@ -100,6 +103,8 @@ export class Tasks { }, project: { name: project.name, + baseTemplate: project.baseTemplate, + unsubscribeFooter: project.unsubscribeFooter, }, isHtml: (campaign && campaign.style === "HTML") ?? (!!action && action.template.style === "HTML"), }), diff --git a/packages/api/src/controllers/v1/Campaigns.ts b/packages/api/src/controllers/v1/Campaigns.ts index c2738156..af15a9ce 100644 --- a/packages/api/src/controllers/v1/Campaigns.ts +++ b/packages/api/src/controllers/v1/Campaigns.ts @@ -123,6 +123,8 @@ export class Campaigns { }, project: { name: project.name, + baseTemplate: project.baseTemplate, + unsubscribeFooter: project.unsubscribeFooter, }, }), }, diff --git a/packages/api/src/controllers/v1/index.ts b/packages/api/src/controllers/v1/index.ts index f84c4e28..a0fae232 100644 --- a/packages/api/src/controllers/v1/index.ts +++ b/packages/api/src/controllers/v1/index.ts @@ -1,5 +1,5 @@ import { ChildControllers, Controller, Middleware, Post } from "@overnightjs/core"; -import { EventSchemas } from "@plunk/shared"; +import { EventSchemas, TemplatingLanguage } from "@plunk/shared"; import dayjs from "dayjs"; import type { Request, Response } from "express"; import signale from "signale"; @@ -200,6 +200,7 @@ export class V1 { plunk_email: contact.email, ...JSON.parse(contact.data ?? "{}"), }, + templatingLanguage: project.templatingLanguage as TemplatingLanguage ?? "DEFAULT", }); const { messageId } = await EmailService.send({ @@ -224,6 +225,8 @@ export class V1 { }, project: { name: project.name, + baseTemplate: project.baseTemplate, + unsubscribeFooter: project.unsubscribeFooter, }, }), }, diff --git a/packages/api/src/services/ActionService.ts b/packages/api/src/services/ActionService.ts index 58ccf612..873aa0d9 100644 --- a/packages/api/src/services/ActionService.ts +++ b/packages/api/src/services/ActionService.ts @@ -5,6 +5,7 @@ import { ContactService } from "./ContactService"; import { EmailService } from "./EmailService"; import { Keys } from "./keys"; import { wrapRedis } from "./redis"; +import { TemplatingLanguage } from "shared/dist"; export class ActionService { /** @@ -116,6 +117,7 @@ export class ActionService { plunk_email: contact.email, ...JSON.parse(contact.data ?? "{}"), }, + templatingLanguage: project.templatingLanguage as TemplatingLanguage, }); const { messageId } = await EmailService.send({ @@ -136,6 +138,8 @@ export class ActionService { }, project: { name: project.name, + baseTemplate: project.baseTemplate, + unsubscribeFooter: project.unsubscribeFooter, }, isHtml: action.template.style === "HTML", }), diff --git a/packages/api/src/services/EmailService.ts b/packages/api/src/services/EmailService.ts index a8293b95..f71943f8 100644 --- a/packages/api/src/services/EmailService.ts +++ b/packages/api/src/services/EmailService.ts @@ -1,6 +1,8 @@ import mjml2html from "mjml"; import { APP_URI, AWS_SES_CONFIGURATION_SET } from "../app/constants"; import { ses } from "../util/ses"; +import { DEFAULT_BASE_TEMPLATE, DEFAULT_FOOTER, TemplatingLanguage } from "@plunk/shared"; +import Handlebars from "handlebars"; export class EmailService { public static async send({ @@ -113,6 +115,8 @@ ${EmailService.breakLongLines(attachment.content, 76, true)} project: { name: string; + baseTemplate: string | null; + unsubscribeFooter: string | null; }; contact: { id: string; @@ -124,397 +128,46 @@ ${EmailService.breakLongLines(attachment.content, 76, true)} }) { const html = content.replace(/ - - - -
-

- You received this email because you agreed to receive emails from ${project.name}. If you no longer wish to receive emails like this, please - update your preferences. -

- - - - ` - : "" -}`; - } - return mjml2html( - ` - - - - .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; - } + const unsubscribeLink = `${APP_URI.startsWith("https://") ? APP_URI : `https://${APP_URI}`}/unsubscribe/${contact.id}`; - .prose tbody tr { - border-bottom: 1px solid #e2e8f0; - } + if(isHtml) { + let unsubscribeFooter: string = ''; - .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; - } + if(footer.unsubscribe) { + const result = mjml2html((project.unsubscribeFooter ?? DEFAULT_FOOTER).replace("{{unsubscribe_url}}", unsubscribeLink).replace("{{project_name}}", project.name)); + if(result.errors.length > 0) { + throw new Error(result.errors[0].message); + } + unsubscribeFooter = result.html.replace(/^\s+|\s+$/g, ""); + } + return `${html} - .prose thead th:last-child { - padding-right: 0; - } +${unsubscribeFooter}`; + } - .prose tbody td:first-child { - padding-left: 0; - } + let unsubscribeFooter: string = ''; - .prose tbody td:last-child { - padding-right: 0; - } + if(footer.unsubscribe) { + unsubscribeFooter = (project.unsubscribeFooter ?? DEFAULT_FOOTER).replace("{{unsubscribe_url}}", unsubscribeLink).replace("{{project_name}}", project.name); + } - .prose > :first-child { - margin-top: 0; - } + const compiledTemplate = (project.baseTemplate ?? DEFAULT_BASE_TEMPLATE).replace("{{html}}", html).replace("{{unsubscribe_footer}}", unsubscribeFooter); - .prose > :last-child { - margin-bottom: 0; - } - - - - - - - - - ${html} - - - - - - - - ${ - footer.unsubscribe - ? ` - - -

- You received this email because you agreed to receive emails from ${project.name}. If you no longer wish to receive emails like this, please update your preferences. -

-
- ` - : "" - } -
-
-
-
`, - ).html.replace(/^\s+|\s+$/g, ""); + const result = mjml2html(compiledTemplate); + if(result.errors.length > 0) { + throw new Error(result.errors[0].message); + } + return result.html.replace(/^\s+|\s+$/g, ""); } - public static format({ subject, body, data }: { subject: string; body: string; data: Record }) { + public static format({ templatingLanguage, subject, body, data }: { templatingLanguage: TemplatingLanguage; subject: string; body: string; data: { plunk_id: string, plunk_email: string} & Record }) { + if (templatingLanguage === "HANDLEBARS") { + Handlebars.registerHelper("default", (expected, defaultValue) => expected ?? defaultValue); + return { + subject: Handlebars.compile(subject)(data), + body: Handlebars.compile(body)(data), + }; + } return { subject: subject.replace(/\{\{(.*?)}}/g, (match, key) => { const [mainKey, defaultValue] = key.split("??").map((s: string) => s.trim()); diff --git a/packages/dashboard/src/components/Navigation/SettingTabs/SettingTabs.tsx b/packages/dashboard/src/components/Navigation/SettingTabs/SettingTabs.tsx index 44cec785..b67acf31 100644 --- a/packages/dashboard/src/components/Navigation/SettingTabs/SettingTabs.tsx +++ b/packages/dashboard/src/components/Navigation/SettingTabs/SettingTabs.tsx @@ -10,6 +10,7 @@ export default function SettingTabs() { const links = [ {to: '/settings/project', text: 'Project Settings', active: router.route === '/settings/project'}, + {to: '/settings/base-template', text: 'Base Template', active: router.route === '/settings/base-template'}, {to: '/settings/api', text: 'API Keys', active: router.route === '/settings/api'}, {to: '/settings/identity', text: 'Verified Domain', active: router.route === '/settings/identity'}, {to: '/settings/members', text: 'Members', active: router.route === '/settings/members'}, diff --git a/packages/dashboard/src/pages/settings/base-template.tsx b/packages/dashboard/src/pages/settings/base-template.tsx new file mode 100644 index 00000000..49c70ac8 --- /dev/null +++ b/packages/dashboard/src/pages/settings/base-template.tsx @@ -0,0 +1,253 @@ +import type { Project } from "@prisma/client"; +import React, { useCallback, useEffect, useState } from "react"; +import { DEFAULT_BASE_TEMPLATE, DEFAULT_FOOTER, ProjectSchemas, TemplatingLanguage } from "@plunk/shared"; +import { Card, Dropdown, FullscreenLoader, SettingTabs } from "../../components"; +import { Dashboard } from "../../layouts"; +import { useActiveProject, useProjects } from "../../lib/hooks/projects"; +import { network } from "../../lib/network"; +import HTMLEditor from "@monaco-editor/react"; + +import { toast } from "sonner"; +import { motion } from "framer-motion"; + +/** + * + */ +export default function Index() { + const [project, setProject] = useState(); + const { data: projects, mutate: projectsMutate } = useProjects(); + const activeProject = useActiveProject(); + const [baseTemplate, setBaseTemplate] = useState(); + const [unsubscribeFooter, setUnsubscribeFooter] = useState(); + const [preview, setPreview] = useState(undefined); + const [templatingLanguage, setTemplatingLanguage] = useState("DEFAULT"); + useEffect(() => { + if (activeProject) { + setBaseTemplate(activeProject.baseTemplate ?? undefined); + setUnsubscribeFooter(activeProject.unsubscribeFooter ?? undefined); + setTemplatingLanguage(activeProject.templatingLanguage as TemplatingLanguage ?? "DEFAULT"); + } + }, [activeProject]); + + useEffect(() => { + setPreview(undefined); + }, [baseTemplate, unsubscribeFooter]); + + const validate = useCallback(() => { + let valid = true; + if (baseTemplate) { + ['{{html}}', '{{unsubscribe_footer}}'].forEach((placeholder) => { + if (!baseTemplate.includes(placeholder)) { + toast.error(`The base template must contain the ${placeholder} placeholder`); + valid = false; + } + }); + } + if (unsubscribeFooter) { + ['{{unsubscribe_url}}', '{{project_name}}'].forEach((placeholder) => { + if (!unsubscribeFooter.includes(placeholder)) { + toast.error(`The unsubscribe footer must contain the ${placeholder} placeholder`); + valid = false; + } + }); + } + + return valid; + }, [baseTemplate, unsubscribeFooter]); + + const update = useCallback(async () => { + if (!activeProject) { + return; + } + + const valid = validate(); + + if (!valid) { + return; + } + + toast.promise( + network.fetch< + { + success: true; + }, + typeof ProjectSchemas.update + >("PUT", "/projects/update/", { + id: activeProject.id, + name: activeProject.name, + url: activeProject.url, + templatingLanguage, + baseTemplate, + unsubscribeFooter, + }), + { + loading: "Updating your project", + success: "Updated your project", + error: "Could not update your project", + }, + ); + + await projectsMutate(); + }, [activeProject, projectsMutate, baseTemplate, unsubscribeFooter, templatingLanguage]); + + + const previewTemplate = useCallback(async () => { + if (!activeProject) { + return; + } + + const valid = validate(); + + if (!valid) { + return; + } + + toast.promise( + async () => { + const body = await network.fetch< + { + success: true; + html: string; + }, + typeof ProjectSchemas.update + >("POST", "/projects/preview/template", { + id: activeProject.id, + name: activeProject.name, + url: activeProject.url, + baseTemplate, + unsubscribeFooter, + templatingLanguage, + }); + setPreview(body.html); + }, + { + loading: "Loading template preview", + success: "Template preview loaded", + error: (data) => `Template preview failed: ${data.message}`, + }, + ) + + }, [activeProject, baseTemplate, unsubscribeFooter, templatingLanguage]); + + if (activeProject && !project) { + setProject(activeProject); + } + + if (!project || !projects) { + return ; + } + + if (!activeProject) { + return ; + } + + return ( + <> + + + +
+
+ + setTemplatingLanguage(t as TemplatingLanguage)} + /> +
+
+ +
+ setBaseTemplate(e as string)} + options={{ + inlineSuggest: true, + fontSize: "12px", + formatOnType: true, + autoClosingBrackets: true, + minimap: { + enabled: false, + }, + }} + /> +
+
+
+ +
+ setUnsubscribeFooter(e as string)} + options={{ + inlineSuggest: true, + fontSize: "12px", + formatOnType: true, + autoClosingBrackets: true, + minimap: { + enabled: false, + }, + }} + /> +
+
+
+
+ + + update()} + className={ + "flex items-center rounded bg-neutral-800 px-5 py-2.5 text-center text-sm font-medium text-white" + } + > + Save + + +
+
+ {preview && ( + +
+