Merge pull request #118 from nklmantey/replace-variables-aws-region

Replace variables aws region
This commit is contained in:
Dries Augustyns
2024-10-29 17:39:42 +01:00
committed by GitHub
23 changed files with 3148 additions and 1865 deletions
-1
View File
@@ -1,5 +1,4 @@
.env .env
.yarn/
.next/ .next/
.github/ .github/
dist/ dist/
+894
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -1 +1,3 @@
nodeLinker: node-modules nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.3.0.cjs
+7 -2
View File
@@ -8,11 +8,16 @@ Support can be asked in the `#contributions` channel of the [Plunk Discord serve
- Docker needs to be [installed](https://docs.docker.com/engine/install/) on your system. - Docker needs to be [installed](https://docs.docker.com/engine/install/) on your system.
### 2. Set your environment variables ### 2. Install dependencies
- Run `yarn install` to install the dependencies.
### 3. Set your environment variables
- Copy the `.env.example` files in the `api`, `dashboard` and `prisma` folder to `.env` in their respective folders. - Copy the `.env.example` files in the `api`, `dashboard` and `prisma` folder to `.env` in their respective folders.
- Set AWS credentials in the `api` `.env` file.
### 3. Start resources ### 4. Start resources
- Run `yarn services:up` to start a local database and a local redis server. - Run `yarn services:up` to start a local database and a local redis server.
- Run `yarn migrate` to apply the migrations to the database. - Run `yarn migrate` to apply the migrations to the database.
Regular → Executable
+21 -5
View File
@@ -7,10 +7,26 @@ if [ -z "${API_URI}" ]; then
exit 1 exit 1
fi fi
# Find and replace baked values with real values for the API_URI if [ -z "${AWS_REGION}" ]; then
find /app/packages/dashboard/public /app/packages/dashboard/.next -type f -name "*.js" | echo "AWS_REGION is not set. Exiting..."
while read file; do exit 1
sed -i "s|PLUNK_API_URI|${API_URI}|g" "$file" fi
# Process each directory that might contain JS files
for dir in "/app/packages/dashboard/public" "/app/packages/dashboard/.next"; do
if [ -d "$dir" ]; then
# Find all JS files and process them
find "$dir" -type f -name "*.js" -o -name "*.mjs" | while read -r file; do
if [ -f "$file" ]; then
# Replace environment variables
sed -i "s|PLUNK_API_URI|${API_URI}|g" "$file"
sed -i "s|PLUNK_AWS_REGION|${AWS_REGION}|g" "$file"
echo "Processed: $file"
fi
done
else
echo "Warning: Directory $dir does not exist, skipping..."
fi
done done
echo "Environment Variables Baked." echo "Environment Variables Baked."
+7 -4
View File
@@ -1,14 +1,16 @@
{ {
"name": "plunk", "name": "plunk",
"version": "1.0.5", "version": "1.0.9",
"private": true, "private": true,
"license": "agpl-3.0", "license": "agpl-3.0",
"workspaces": { "workspaces": {
"packages": ["packages/*"] "packages": [
"packages/*"
]
}, },
"engines": { "engines": {
"npm": ">=6.14.x", "npm": ">=6.14.x",
"yarn": "1.22.x", "yarn": "4.3.x",
"node": ">=18.x" "node": ">=18.x"
}, },
"devDependencies": { "devDependencies": {
@@ -34,5 +36,6 @@
"generate": "prisma generate", "generate": "prisma generate",
"services:up": "docker compose -f docker-compose.dev.yml up -d", "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"
} },
"packageManager": "[email protected]"
} }
+1 -1
View File
@@ -1,7 +1,7 @@
# ENV # ENV
JWT_SECRET=mysupersecretJWTsecret JWT_SECRET=mysupersecretJWTsecret
REDIS_URL=redis://127.0.0.1:56379 REDIS_URL=redis://127.0.0.1:56379
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres DATABASE_URL=postgresql://postgres:postgres@localhost:55432/postgres
DISABLE_SIGNUPS=false DISABLE_SIGNUPS=false
# AWS # AWS
+16 -6
View File
@@ -4,12 +4,22 @@ import { API_URI } from "./constants";
export const task = cron.schedule("* * * * *", () => { export const task = cron.schedule("* * * * *", () => {
signale.info("Running scheduled tasks"); signale.info("Running scheduled tasks");
void fetch(`${API_URI}/tasks`, { try {
method: "POST", void fetch(`${API_URI}/tasks`, {
}); method: "POST",
});
} catch (e) {
signale.error("Failed to run scheduled tasks. Please check the error below");
console.error(e);
}
signale.info("Updating verified identities"); signale.info("Updating verified identities");
void fetch(`${API_URI}/identities/update`, { try {
method: "POST", void fetch(`${API_URI}/identities/update`, {
}); method: "POST",
});
} catch (e) {
signale.error("Failed to update verified identities. Please check the error below");
console.error(e);
}
}); });
+11 -2
View File
@@ -41,6 +41,9 @@ export class Tasks {
let subject = ""; let subject = "";
let body = ""; let body = "";
let email = "";
let name = "";
if (action) { if (action) {
const { template, notevents } = action; const { template, notevents } = action;
@@ -52,6 +55,9 @@ export class Tasks {
} }
} }
email = project.verified && project.email ? template.email ?? project.email : "[email protected]";
name = template.from ?? project.from ?? project.name;
({ subject, body } = EmailService.format({ ({ subject, body } = EmailService.format({
subject: template.subject, subject: template.subject,
body: template.body, body: template.body,
@@ -62,6 +68,9 @@ export class Tasks {
}, },
})); }));
} else if (campaign) { } else if (campaign) {
email = project.verified && project.email ? campaign.email ?? project.email : "[email protected]";
name = campaign.from ?? project.from ?? project.name;
({ subject, body } = EmailService.format({ ({ subject, body } = EmailService.format({
subject: campaign.subject, subject: campaign.subject,
body: campaign.body, body: campaign.body,
@@ -75,8 +84,8 @@ export class Tasks {
const { messageId } = await EmailService.send({ const { messageId } = await EmailService.send({
from: { from: {
name: project.from ?? project.name, name,
email: project.verified && project.email ? project.email : "[email protected]", email,
}, },
to: [contact.email], to: [contact.email],
content: { content: {
+25 -3
View File
@@ -3,7 +3,7 @@ import { CampaignSchemas, UtilitySchemas } from "@plunk/shared";
import dayjs from "dayjs"; import dayjs from "dayjs";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { prisma } from "../../database/prisma"; import { prisma } from "../../database/prisma";
import { HttpException, NotFound } from "../../exceptions"; import { HttpException, NotAllowed, NotFound } from "../../exceptions";
import { type IJwt, type ISecret, isAuthenticated, isValidSecretKey } from "../../middleware/auth"; import { type IJwt, type ISecret, isAuthenticated, isValidSecretKey } from "../../middleware/auth";
import { CampaignService } from "../../services/CampaignService"; import { CampaignService } from "../../services/CampaignService";
import { ContactService } from "../../services/ContactService"; import { ContactService } from "../../services/ContactService";
@@ -160,6 +160,8 @@ export class Campaigns {
subject: campaign.subject, subject: campaign.subject,
body: campaign.body, body: campaign.body,
style: campaign.style, style: campaign.style,
email: campaign.email,
from: campaign.from,
}, },
}); });
@@ -180,7 +182,15 @@ export class Campaigns {
throw new NotFound("project"); throw new NotFound("project");
} }
let { subject, body, recipients, style } = CampaignSchemas.create.parse(req.body); let { subject, body, recipients, style, email, from } = CampaignSchemas.create.parse(req.body);
if (email && !project.verified) {
throw new NotAllowed("You need to attach a domain to your project to customize the sender address");
}
if (email && email.split("@")[1] !== project.email?.split("@")[1]) {
throw new NotAllowed("The sender address must be the same domain as the project's email address");
}
if (recipients.length === 1 && recipients[0] === "all") { if (recipients.length === 1 && recipients[0] === "all") {
const projectContacts = await prisma.contact.findMany({ const projectContacts = await prisma.contact.findMany({
@@ -197,6 +207,8 @@ export class Campaigns {
subject, subject,
body, body,
style, style,
from: from === "" ? null : from,
email: email === "" ? null : email,
}, },
}); });
@@ -253,7 +265,15 @@ export class Campaigns {
throw new NotFound("project"); throw new NotFound("project");
} }
let { id, subject, body, recipients, style } = CampaignSchemas.update.parse(req.body); let { id, subject, body, recipients, style, email, from } = CampaignSchemas.update.parse(req.body);
if (email && !project.verified) {
throw new NotAllowed("You need to attach a domain to your project to customize the sender address");
}
if (email && email.split("@")[1] !== project.email?.split("@")[1]) {
throw new NotAllowed("The sender address must be the same domain as the project's email address");
}
if (recipients.length === 1 && recipients[0] === "all") { if (recipients.length === 1 && recipients[0] === "all") {
const projectContacts = await prisma.contact.findMany({ const projectContacts = await prisma.contact.findMany({
@@ -276,6 +296,8 @@ export class Campaigns {
subject, subject,
body, body,
style, style,
from: from === "" ? null : from,
email: email === "" ? null : email,
}, },
include: { include: {
recipients: { select: { id: true } }, recipients: { select: { id: true } },
+30 -3
View File
@@ -60,6 +60,8 @@ export class Templates {
body: template.body, body: template.body,
type: template.type, type: template.type,
style: template.style, style: template.style,
email: template.email,
from: template.from,
}, },
}); });
@@ -101,7 +103,15 @@ export class Templates {
throw new NotFound("project"); throw new NotFound("project");
} }
const { subject, body, type, style } = TemplateSchemas.create.parse(req.body); const { subject, body, type, style, email, from } = TemplateSchemas.create.parse(req.body);
if (email && !project.verified) {
throw new NotAllowed("You need to attach a domain to your project to customize the sender address");
}
if (email && email.split("@")[1] !== project.email?.split("@")[1]) {
throw new NotAllowed("The sender address must be the same domain as the project's email address");
}
const template = await prisma.template.create({ const template = await prisma.template.create({
data: { data: {
@@ -110,6 +120,8 @@ export class Templates {
body, body,
type, type,
style, style,
from: from === "" ? null : from,
email: email === "" ? null : email,
}, },
}); });
@@ -151,7 +163,7 @@ export class Templates {
throw new NotFound("project"); throw new NotFound("project");
} }
const { id, subject, body, type, style } = TemplateSchemas.update.parse(req.body); const { id, subject, body, type, style, email, from } = TemplateSchemas.update.parse(req.body);
let template = await TemplateService.id(id); let template = await TemplateService.id(id);
@@ -159,9 +171,24 @@ export class Templates {
throw new NotFound("template"); throw new NotFound("template");
} }
if (email && !project.verified) {
throw new NotAllowed("You need to attach a domain to your project to customize the sender address");
}
if (email && email.split("@")[1] !== project.email?.split("@")[1]) {
throw new NotAllowed("The sender address must be the same domain as the project's email address");
}
template = await prisma.template.update({ template = await prisma.template.update({
where: { id }, where: { id },
data: { subject, body, type, style }, data: {
subject,
body,
type,
style,
from: from === "" ? null : from,
email: email === "" ? null : email,
},
include: { include: {
actions: true, actions: true,
}, },
+4 -7
View File
@@ -78,15 +78,12 @@ export class V1 {
redis.del(Keys.Contact.id(contact.id)); redis.del(Keys.Contact.id(contact.id));
redis.del(Keys.Contact.email(project.id, contact.email)); redis.del(Keys.Contact.email(project.id, contact.email));
} else { } else {
if (subscribed && contact.subscribed !== subscribed) { if (subscribed !== null && contact.subscribed !== subscribed) {
contact = await prisma.contact.update({ contact = await prisma.contact.update({where: {id: contact.id}, data: {subscribed}});
where: { id: contact.id },
data: { subscribed },
});
redis.del(Keys.Contact.id(contact.id)); redis.del(Keys.Contact.id(contact.id));
redis.del(Keys.Contact.email(project.id, contact.email)); redis.del(Keys.Contact.email(project.id, contact.email));
} }
} }
if (data) { if (data) {
+2 -2
View File
@@ -120,8 +120,8 @@ export class ActionService {
const { messageId } = await EmailService.send({ const { messageId } = await EmailService.send({
from: { from: {
name: project.from ?? project.name, name: action.template.from ?? project.from ?? project.name,
email: project.verified && project.email ? project.email : "[email protected]", email: project.verified && project.email ? action.template.email ?? project.email : "[email protected]",
}, },
to: [contact.email], to: [contact.email],
content: { content: {
+2 -2
View File
@@ -107,7 +107,7 @@ ${
<hr style="border: none; border-top: 1px solid #eaeaea; width: 100%; margin-top: 12px; margin-bottom: 12px;"> <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);"> <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 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>. <a href="${APP_URI.startsWith("https://") ? APP_URI : `https://${APP_URI}`}/unsubscribe/${contact.id}">update your preferences</a>.
</p> </p>
</td> </td>
</tr> </tr>
@@ -472,7 +472,7 @@ ${
<mj-divider border-width="2px" border-color="#f5f5f5"></mj-divider> <mj-divider border-width="2px" border-color="#f5f5f5"></mj-divider>
<mj-text align="center"> <mj-text align="center">
<p style="color: #a3a3a3; text-decoration: none; font-size: 12px; line-height: 1.7142857;"> <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="${APP_URI}/unsubscribe/${contact.id}" target="_blank">update your preferences</a>. 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="${APP_URI.startsWith("https://") ? APP_URI : `https://${APP_URI}`}/unsubscribe/${contact.id}" target="_blank">update your preferences</a>.
</p> </p>
</mj-text> </mj-text>
` `
@@ -32,6 +32,8 @@ import { network } from "../../lib/network";
interface CampaignValues { interface CampaignValues {
subject: string; subject: string;
body: string; body: string;
email?: string;
from?: string;
recipients: string[]; recipients: string[];
style: "PLUNK" | "HTML"; style: "PLUNK" | "HTML";
} }
@@ -71,6 +73,8 @@ export default function Index() {
watch, watch,
reset, reset,
setValue, setValue,
setError,
clearErrors,
} = useForm<CampaignValues>({ } = useForm<CampaignValues>({
resolver: zodResolver(CampaignSchemas.update), resolver: zodResolver(CampaignSchemas.update),
defaultValues: { recipients: [], body: undefined }, defaultValues: { recipients: [], body: undefined },
@@ -87,6 +91,21 @@ export default function Index() {
}); });
}, [reset, campaign]); }, [reset, campaign]);
useEffect(() => {
watch((value, { name, type }) => {
if (name === "email") {
if (value.email && project?.email && !value.email.endsWith(project.email.split("@")[1])) {
setError("email", {
type: "manual",
message: `The sender address must end with @${project.email?.split("@")[1]}`,
});
} else {
clearErrors("email");
}
}
});
}, [watch, project, setError, clearErrors]);
if (!project || !campaign || !events || (watch("body") as string | undefined) === undefined) { if (!project || !campaign || !events || (watch("body") as string | undefined) === undefined) {
return <FullscreenLoader />; return <FullscreenLoader />;
} }
@@ -404,13 +423,35 @@ export default function Index() {
} }
> >
<form onSubmit={handleSubmit(update)} className="space-6 grid gap-6 sm:grid-cols-6"> <form onSubmit={handleSubmit(update)} className="space-6 grid gap-6 sm:grid-cols-6">
<Input <div className={"sm:col-span-6 grid sm:grid-cols-6 gap-6"}>
className={"sm:col-span-6"} <Input
label={"Subject"} className={"sm:col-span-6"}
placeholder={`Welcome to ${project.name}!`} label={"Subject"}
register={register("subject")} placeholder={`Welcome to ${project.name}!`}
error={errors.subject} register={register("subject")}
/> error={errors.subject}
/>
{project.verified && (
<Input
className={"sm:col-span-3"}
label={"Sender Email"}
placeholder={`${project.email}`}
register={register("email")}
error={errors.email}
/>
)}
{project.verified && (
<Input
className={"sm:col-span-3"}
label={"Sender Name"}
placeholder={`${project.from ?? project.name}`}
register={register("from")}
error={errors.from}
/>
)}
</div>
{contacts ? ( {contacts ? (
<> <>
+50 -8
View File
@@ -6,7 +6,7 @@ import dayjs from "dayjs";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { Search, Users2, XIcon } from "lucide-react"; import { Search, Users2, XIcon } from "lucide-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import { type FieldError, useForm } from "react-hook-form"; import { type FieldError, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { Alert, Card, Dropdown, Editor, FullscreenLoader, Input, MultiselectDropdown } from "../../components"; import { Alert, Card, Dropdown, Editor, FullscreenLoader, Input, MultiselectDropdown } from "../../components";
@@ -20,6 +20,8 @@ import { network } from "../../lib/network";
interface CampaignValues { interface CampaignValues {
subject: string; subject: string;
body: string; body: string;
email?: string;
from?: string;
recipients: string[]; recipients: string[];
style: "PLUNK" | "HTML"; style: "PLUNK" | "HTML";
} }
@@ -58,6 +60,8 @@ export default function Index() {
formState: { errors }, formState: { errors },
setValue, setValue,
watch, watch,
setError,
clearErrors,
} = useForm<CampaignValues>({ } = useForm<CampaignValues>({
resolver: zodResolver(CampaignSchemas.create), resolver: zodResolver(CampaignSchemas.create),
defaultValues: { defaultValues: {
@@ -67,6 +71,21 @@ export default function Index() {
}, },
}); });
useEffect(() => {
watch((value, { name, type }) => {
if (name === "email") {
if (value.email && project?.email && !value.email.endsWith(project.email.split("@")[1])) {
setError("email", {
type: "manual",
message: `The sender address must end with @${project.email?.split("@")[1]}`,
});
} else {
clearErrors("email");
}
}
});
}, [watch, project, setError, clearErrors]);
if (!project || !events) { if (!project || !events) {
return <FullscreenLoader />; return <FullscreenLoader />;
} }
@@ -194,13 +213,36 @@ export default function Index() {
<Dashboard> <Dashboard>
<Card title={"Create a new campaign"}> <Card title={"Create a new campaign"}>
<form onSubmit={handleSubmit(create)} className="space-6 grid gap-6 sm:grid-cols-6"> <form onSubmit={handleSubmit(create)} className="space-6 grid gap-6 sm:grid-cols-6">
<Input <div className={"sm:col-span-6 grid sm:grid-cols-6 gap-6"}>
className={"sm:col-span-6"} <Input
label={"Subject"} className={"sm:col-span-6"}
placeholder={`Welcome to ${project.name}!`} label={"Subject"}
register={register("subject")} placeholder={`Welcome to ${project.name}!`}
error={errors.subject} register={register("subject")}
/> error={errors.subject}
/>
{project.verified && (
<Input
className={"sm:col-span-3"}
label={"Sender Email"}
placeholder={`${project.email}`}
register={register("email")}
error={errors.email}
/>
)}
{project.verified && (
<Input
className={"sm:col-span-3"}
label={"Sender Name"}
placeholder={`${project.from ?? project.name}`}
register={register("from")}
error={errors.from}
/>
)}
</div>
{contacts ? ( {contacts ? (
<> <>
<div className={"sm:col-span-3"}> <div className={"sm:col-span-3"}>
+279 -276
View File
@@ -12,305 +12,308 @@ import { useActiveProject, useActiveProjectVerifiedIdentity, useProjects } from
import { network } from "../../lib/network"; import { network } from "../../lib/network";
interface EmailValues { interface EmailValues {
email: string; email: string;
} }
interface FromValues { interface FromValues {
from: string; from: string;
} }
/** /**
* *
*/ */
export default function Index() { export default function Index() {
const activeProject = useActiveProject(); const activeProject = useActiveProject();
const { mutate: projectsMutate } = useProjects(); const { mutate: projectsMutate } = useProjects();
const { data: identity, mutate: identityMutate } = useActiveProjectVerifiedIdentity(); const { data: identity, mutate: identityMutate } = useActiveProjectVerifiedIdentity();
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors },
} = useForm<EmailValues>({ } = useForm<EmailValues>({
resolver: zodResolver(IdentitySchemas.create.omit({ id: true })), resolver: zodResolver(IdentitySchemas.create.omit({ id: true })),
}); });
const { const {
register: registerUpdate, register: registerUpdate,
handleSubmit: handleSubmitUpdate, handleSubmit: handleSubmitUpdate,
formState: { errors: errorsUpdate }, formState: { errors: errorsUpdate },
reset, reset,
} = useForm<FromValues>({ } = useForm<FromValues>({
resolver: zodResolver(IdentitySchemas.update.omit({ id: true })), resolver: zodResolver(IdentitySchemas.update.omit({ id: true })),
}); });
useEffect(() => { useEffect(() => {
if (!activeProject) { if (!activeProject) {
return; return;
} }
reset({ from: activeProject.from ?? undefined }); reset({ from: activeProject.from ?? undefined });
}, [reset, activeProject]); }, [reset, activeProject]);
if (!activeProject || !identity) { if (!activeProject || !identity) {
return <FullscreenLoader />; return <FullscreenLoader />;
} }
const create = async (data: EmailValues) => { const create = async (data: EmailValues) => {
toast.promise( toast.promise(
network.fetch< network.fetch<
{ {
success: true; success: true;
tokens: string[]; tokens: string[];
}, },
typeof IdentitySchemas.create typeof IdentitySchemas.create
>("POST", "/identities/create", { >("POST", "/identities/create", {
id: activeProject.id, id: activeProject.id,
...data, ...data,
}), }),
{ {
loading: "Adding your domain", loading: "Adding your domain",
success: (res) => { success: (res) => {
void identityMutate({ tokens: res.tokens }, { revalidate: false }); void identityMutate({ tokens: res.tokens }, { revalidate: false });
void projectsMutate(); void projectsMutate();
return "Added your domain"; return "Added your domain";
}, },
error: "Could not add domain", error: "Could not add domain",
}, },
); );
}; };
const update = async (data: FromValues) => { const update = async (data: FromValues) => {
toast.promise( toast.promise(
network.fetch< network.fetch<
{ {
success: true; success: true;
}, },
typeof IdentitySchemas.update typeof IdentitySchemas.update
>("PUT", "/projects/update/identity", { >("PUT", "/projects/update/identity", {
id: activeProject.id, id: activeProject.id,
...data, ...data,
}), }),
{ {
loading: "Updating your sender name", loading: "Updating your sender name",
success: "Updated your sender name", success: "Updated your sender name",
error: "Could not update sender name", error: "Could not update sender name",
}, },
); );
await identityMutate(); await identityMutate();
await projectsMutate(); await projectsMutate();
}; };
const unlink = async () => { const unlink = async () => {
toast.promise( toast.promise(
network.fetch< network.fetch<
{ {
success: true; success: true;
}, },
typeof UtilitySchemas.id typeof UtilitySchemas.id
>("POST", "/identities/reset", { >("POST", "/identities/reset", {
id: activeProject.id, id: activeProject.id,
}), }),
{ {
loading: "Unlinking your domain", loading: "Unlinking your domain",
success: "Unlinked your domain", success: "Unlinked your domain",
error: "Could not unlink domain", error: "Could not unlink domain",
}, },
); );
window.location.reload(); window.location.reload();
}; };
return ( const domain = activeProject.email?.split("@")[1] ?? "";
<> const subdomain = domain.split(".").length > 2 ? domain.split(".")[0] : "";
<Dashboard>
<SettingTabs />
<Card return (
title={"Domain"} <>
description={"By sending emails from your own domain you build up domain authority and trust."} <Dashboard>
actions={ <SettingTabs />
activeProject.email && (
<>
<button
onClick={unlink}
className={
"flex items-center gap-x-2 rounded bg-red-600 px-8 py-2 text-center text-sm font-medium text-white transition ease-in-out hover:bg-red-700"
}
>
<Unlink strokeWidth={1.5} size={18} />
Unlink domain
</button>
</>
)
}
>
{activeProject.email && !activeProject.verified ? (
<>
<Alert type={"warning"} title={"Waiting for DNS verification"}>
Please add the following records to {activeProject.email.split("@")[1]} to verify {activeProject.email}, this
may take up to 15 minutes to register. <br />
In the meantime you can already start sending emails, we will automatically switch to your domain once it is
verified.
</Alert>
<div className="mt-6"> <Card
<Table title={"Domain"}
values={[ description={"By sending emails from your own domain you build up domain authority and trust."}
{ actions={
Type: <Badge type={"info"}>TXT</Badge>, activeProject.email && (
Key: ( <>
<div <button
className={"flex cursor-pointer items-center gap-3"} onClick={unlink}
onClick={() => { className={
void navigator.clipboard.writeText("plunk"); "flex items-center gap-x-2 rounded bg-red-600 px-8 py-2 text-center text-sm font-medium text-white transition ease-in-out hover:bg-red-700"
toast.success("Copied key to clipboard"); }
}} >
> <Unlink strokeWidth={1.5} size={18} />
<p className={"font-mono text-sm"}>plunk</p> Unlink domain
<Copy size={14} /> </button>
</div> </>
), )
Value: ( }
<div >
className={"flex cursor-pointer items-center gap-3"} {activeProject.email && !activeProject.verified ? (
onClick={() => { <>
void navigator.clipboard.writeText("v=spf1 include:amazonses.com ~all"); <Alert type={"warning"} title={"Waiting for DNS verification"}>
toast.success("Copied value to clipboard"); Please add the following records to {activeProject.email.split("@")[1]} to verify {activeProject.email}, this
}} may take up to 15 minutes to register. <br />
> In the meantime you can already start sending emails, we will automatically switch to your domain once it is
<p className={"font-mono text-sm"}>v=spf1 include:amazonses.com ~all</p> <Copy size={14} /> verified.
</div> </Alert>
),
},
{
type: <Badge type={"info"}>MX</Badge>,
Key: (
<div
className={"flex cursor-pointer items-center gap-3"}
onClick={() => {
void navigator.clipboard.writeText("plunk");
toast.success("Copied key to clipboard");
}}
>
<p className={"font-mono text-sm"}>plunk</p>
<Copy size={14} />
</div>
),
Value: (
<div
className={"flex cursor-pointer items-center gap-3"}
onClick={() => {
void navigator.clipboard.writeText(`10 feedback-smtp.${AWS_REGION}.amazonses.com`);
toast.success("Copied value to clipboard");
}}
>
<p className={"font-mono text-sm"}>10 feedback-smtp.{AWS_REGION}.amazonses.com</p>
<Copy size={14} />
</div>
),
},
...identity.tokens.map((token) => {
return {
Type: <Badge type={"info"}>CNAME</Badge>,
Key: (
<div
className={"flex cursor-pointer items-center gap-3"}
onClick={() => {
void navigator.clipboard.writeText(`${token}._domainkey`);
toast.success("Copied key to clipboard");
}}
>
<p className={"font-mono text-sm"}>{token}._domainkey</p>
<Copy size={14} />
</div>
),
Value: (
<div
className={"flex cursor-pointer items-center gap-3"}
onClick={() => {
void navigator.clipboard.writeText(`${token}.dkim.amazonses.com`);
toast.success("Copied value to clipboard");
}}
>
<p className={"font-mono text-sm"}>{token}.dkim.amazonses.com</p>
<Copy size={14} />
</div>
),
};
}),
]}
/>
</div>
</>
) : activeProject.email && activeProject.verified ? (
<>
<Alert type={"success"} title={"Domain verified"}>
You have confirmed {activeProject.email} as your domain. Any emails sent by Plunk will now use this address.
</Alert>
</>
) : (
<>
<form onSubmit={handleSubmit(create)} className="space-y-6">
<Input register={register("email")} error={errors.email} placeholder={"[email protected]"} label={"Email"} />
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.9 }}
className={
"ml-auto flex items-center gap-x-0.5 rounded bg-neutral-800 px-8 py-2 text-center text-sm font-medium text-white"
}
>
<svg width="24" height="24" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M12 5.75V18.25"
/>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M18.25 12L5.75 12"
/>
</svg>
Verify domain
</motion.button>
</form>
</>
)}
</Card>
<Card <div className="mt-6">
title={"Sender name"} <Table
description={ values={[
"The name that will be used when sending emails from Plunk. Your project name will be used by default" {
} Type: <Badge type={"info"}>TXT</Badge>,
> Key: (
<form onSubmit={handleSubmitUpdate(update)} className="space-y-6"> <div
<Input className={"flex cursor-pointer items-center gap-3"}
register={registerUpdate("from")} onClick={() => {
placeholder={activeProject.name} void navigator.clipboard.writeText("plunk");
label={"Name"} toast.success("Copied key to clipboard");
error={errorsUpdate.from} }}
/> >
<p className={"font-mono text-sm"}>plunk</p>
<Copy size={14} />
</div>
),
Value: (
<div
className={"flex cursor-pointer items-center gap-3"}
onClick={() => {
void navigator.clipboard.writeText("v=spf1 include:amazonses.com ~all");
toast.success("Copied value to clipboard");
}}
>
<p className={"font-mono text-sm"}>v=spf1 include:amazonses.com ~all</p> <Copy size={14} />
</div>
),
},
{
type: <Badge type={"info"}>MX</Badge>,
Key: (
<div
className={"flex cursor-pointer items-center gap-3"}
onClick={() => {
void navigator.clipboard.writeText("plunk");
toast.success("Copied key to clipboard");
}}
>
<p className={"font-mono text-sm"}>plunk</p>
<Copy size={14} />
</div>
),
Value: (
<div
className={"flex cursor-pointer items-center gap-3"}
onClick={() => {
void navigator.clipboard.writeText(`10 feedback-smtp.${AWS_REGION}.amazonses.com`);
toast.success("Copied value to clipboard");
}}
>
<p className={"font-mono text-sm"}>10 feedback-smtp.{AWS_REGION}.amazonses.com</p>
<Copy size={14} />
</div>
),
},
...identity.tokens.map((token) => {
return {
Type: <Badge type={"info"}>CNAME</Badge>,
Key: (
<div
className={"flex cursor-pointer items-center gap-3"}
onClick={() => {
void navigator.clipboard.writeText(`${token}._domainkey${subdomain ? '.' + subdomain : ''}`);
toast.success("Copied key to clipboard");
}}
>
<p className={"font-mono text-sm"}>{token}._domainkey{subdomain ? '.' + subdomain : ''}</p>
<Copy size={14} />
</div>
),
Value: (
<div
className={"flex cursor-pointer items-center gap-3"}
onClick={() => {
void navigator.clipboard.writeText(`${token}.dkim.amazonses.com`);
toast.success("Copied value to clipboard");
}}
>
<p className={"font-mono text-sm"}>{token}.dkim.amazonses.com</p>
<Copy size={14} />
</div>
),
};
}),
]}
/>
</div>
</>
) : activeProject.email && activeProject.verified ? (
<>
<Alert type={"success"} title={"Domain verified"}>
You have confirmed {activeProject.email} as your domain. Any emails sent by Plunk will now use this address.
</Alert>
</>
) : (
<>
<form onSubmit={handleSubmit(create)} className="space-y-6">
<Input register={register("email")} error={errors.email} placeholder={"[email protected]"} label={"Email"} />
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.9 }}
className={
"ml-auto flex items-center gap-x-0.5 rounded bg-neutral-800 px-8 py-2 text-center text-sm font-medium text-white"
}
>
<svg width="24" height="24" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M12 5.75V18.25"
/>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M18.25 12L5.75 12"
/>
</svg>
Verify domain
</motion.button>
</form>
</>
)}
</Card>
<motion.button <Card
whileHover={{ scale: 1.05 }} title={"Sender name"}
whileTap={{ scale: 0.9 }} description={
className={ "The name that will be used when sending emails from Plunk. Your project name will be used by default"
"ml-auto flex items-center gap-x-0.5 rounded bg-neutral-800 px-8 py-2 text-center text-sm font-medium text-white" }
} >
> <form onSubmit={handleSubmitUpdate(update)} className="space-y-6">
Save <Input
</motion.button> register={registerUpdate("from")}
</form> placeholder={activeProject.name}
</Card> label={"Name"}
</Dashboard> error={errorsUpdate.from}
</> />
);
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.9 }}
className={
"ml-auto flex items-center gap-x-0.5 rounded bg-neutral-800 px-8 py-2 text-center text-sm font-medium text-white"
}
>
Save
</motion.button>
</form>
</Card>
</Dashboard>
</>
);
} }
@@ -16,6 +16,8 @@ import { network } from "../../lib/network";
interface TemplateValues { interface TemplateValues {
subject: string; subject: string;
body: string; body: string;
email?: string;
from?: string;
type: "MARKETING" | "TRANSACTIONAL"; type: "MARKETING" | "TRANSACTIONAL";
style: "PLUNK" | "HTML"; style: "PLUNK" | "HTML";
} }
@@ -41,6 +43,8 @@ export default function Index() {
watch, watch,
setValue, setValue,
reset, reset,
setError,
clearErrors,
} = useForm<TemplateValues>({ } = useForm<TemplateValues>({
resolver: zodResolver(TemplateSchemas.update), resolver: zodResolver(TemplateSchemas.update),
defaultValues: { defaultValues: {
@@ -56,6 +60,21 @@ export default function Index() {
reset(template); reset(template);
}, [reset, template]); }, [reset, template]);
useEffect(() => {
watch((value, { name, type }) => {
if (name === "email") {
if (value.email && project?.email && !value.email.endsWith(project.email.split("@")[1])) {
setError("email", {
type: "manual",
message: `The sender address must end with @${project.email?.split("@")[1]}`,
});
} else {
clearErrors("email");
}
}
});
}, [watch, project, setError, clearErrors]);
if (!project || !template || (watch("body") as string | undefined) === undefined) { if (!project || !template || (watch("body") as string | undefined) === undefined) {
return <FullscreenLoader />; return <FullscreenLoader />;
} }
@@ -248,6 +267,26 @@ export default function Index() {
</AnimatePresence> </AnimatePresence>
</div> </div>
{project.verified && (
<Input
className={"sm:col-span-3"}
label={"Sender Email"}
placeholder={`${project.email}`}
register={register("email")}
error={errors.email}
/>
)}
{project.verified && (
<Input
className={"sm:col-span-3"}
label={"Sender Name"}
placeholder={`${project.from ?? project.name}`}
register={register("from")}
error={errors.from}
/>
)}
<div className={"sm:col-span-6"}> <div className={"sm:col-span-6"}>
<Editor <Editor
value={watch("body")} value={watch("body")}
+40 -1
View File
@@ -3,7 +3,7 @@ import { TemplateSchemas } from "@plunk/shared";
import type { Template } from "@prisma/client"; import type { Template } from "@prisma/client";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React from "react"; import React, { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { Card, Dropdown, Editor, FullscreenLoader, Input, Tooltip } from "../../components"; import { Card, Dropdown, Editor, FullscreenLoader, Input, Tooltip } from "../../components";
@@ -15,6 +15,8 @@ import { network } from "../../lib/network";
interface TemplateValues { interface TemplateValues {
subject: string; subject: string;
body: string; body: string;
email?: string;
from?: string;
type: "MARKETING" | "TRANSACTIONAL"; type: "MARKETING" | "TRANSACTIONAL";
style: "PLUNK" | "HTML"; style: "PLUNK" | "HTML";
} }
@@ -41,6 +43,8 @@ export default function Index() {
formState: { errors }, formState: { errors },
watch, watch,
setValue, setValue,
setError,
clearErrors,
} = useForm<TemplateValues>({ } = useForm<TemplateValues>({
resolver: zodResolver(TemplateSchemas.create), resolver: zodResolver(TemplateSchemas.create),
defaultValues: { defaultValues: {
@@ -50,6 +54,21 @@ export default function Index() {
}, },
}); });
useEffect(() => {
watch((value, { name, type }) => {
if (name === "email") {
if (value.email && project?.email && !value.email.endsWith(project.email.split("@")[1])) {
setError("email", {
type: "manual",
message: `The sender address must end with @${project.email?.split("@")[1]}`,
});
} else {
clearErrors("email");
}
}
});
}, [watch, project, setError, clearErrors]);
if (!project) { if (!project) {
return <FullscreenLoader />; return <FullscreenLoader />;
} }
@@ -141,6 +160,26 @@ export default function Index() {
</AnimatePresence> </AnimatePresence>
</div> </div>
{project.verified && (
<Input
className={"sm:col-span-3"}
label={"Sender Email"}
placeholder={`${project.email}`}
register={register("email")}
error={errors.email}
/>
)}
{project.verified && (
<Input
className={"sm:col-span-3"}
label={"Sender Name"}
placeholder={`${project.from ?? project.name}`}
register={register("from")}
error={errors.from}
/>
)}
<div className={"sm:col-span-6"}> <div className={"sm:col-span-6"}>
<Editor <Editor
value={watch("body")} value={watch("body")}
+8
View File
@@ -133,6 +133,8 @@ export const CampaignSchemas = {
.min(1, "Subject needs to be at least 1 character long") .min(1, "Subject needs to be at least 1 character long")
.max(70, "Subject needs to be less than 70 characters long"), .max(70, "Subject needs to be less than 70 characters long"),
body: z.string().min(1, "Body needs to be at least 1 character long"), body: z.string().min(1, "Body needs to be at least 1 character long"),
email: email.nullish().or(z.literal("")),
from: z.string().nullish(),
recipients: z.array(z.string()), recipients: z.array(z.string()),
style: z.nativeEnum(TemplateStyle).default("PLUNK"), style: z.nativeEnum(TemplateStyle).default("PLUNK"),
}), }),
@@ -143,6 +145,8 @@ export const CampaignSchemas = {
.min(1, "Subject needs to be at least 1 character long") .min(1, "Subject needs to be at least 1 character long")
.max(70, "Subject needs to be less than 70 characters long"), .max(70, "Subject needs to be less than 70 characters long"),
body: z.string().min(1, "Body needs to be at least 1 character long"), body: z.string().min(1, "Body needs to be at least 1 character long"),
email: email.nullish().or(z.literal("")),
from: z.string().nullish(),
recipients: z.array(z.string()), recipients: z.array(z.string()),
style: z.nativeEnum(TemplateStyle).default("PLUNK"), style: z.nativeEnum(TemplateStyle).default("PLUNK"),
}), }),
@@ -208,6 +212,8 @@ export const TemplateSchemas = {
create: z.object({ create: z.object({
subject: z.string().min(1, "Subject can't be empty").max(70, "Subject needs to be less than 70 characters long"), subject: z.string().min(1, "Subject can't be empty").max(70, "Subject needs to be less than 70 characters long"),
body: z.string().min(1, "Body can't be empty"), body: z.string().min(1, "Body can't be empty"),
email: email.nullish().or(z.literal("")),
from: z.string().nullish(),
type: z.nativeEnum(TemplateType).default("MARKETING"), type: z.nativeEnum(TemplateType).default("MARKETING"),
style: z.nativeEnum(TemplateStyle).default("PLUNK"), style: z.nativeEnum(TemplateStyle).default("PLUNK"),
}), }),
@@ -215,6 +221,8 @@ export const TemplateSchemas = {
id, id,
subject: z.string().min(1, "Subject can't be empty").max(70, "Subject needs to be less than 70 characters long"), subject: z.string().min(1, "Subject can't be empty").max(70, "Subject needs to be less than 70 characters long"),
body: z.string().min(1, "Body can't be empty"), body: z.string().min(1, "Body can't be empty"),
email: email.nullish().or(z.literal("")),
from: z.string().nullish(),
type: z.nativeEnum(TemplateType).default("MARKETING"), type: z.nativeEnum(TemplateType).default("MARKETING"),
style: z.nativeEnum(TemplateStyle).default("PLUNK"), style: z.nativeEnum(TemplateStyle).default("PLUNK"),
}), }),
@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "campaigns" ADD COLUMN "email" TEXT,
ADD COLUMN "from" TEXT;
-- AlterTable
ALTER TABLE "templates" ADD COLUMN "email" TEXT,
ADD COLUMN "from" TEXT;
+7 -2
View File
@@ -108,6 +108,8 @@ model Template {
// Details // Details
subject String subject String
body String body String
email String?
from String?
type TemplateType type TemplateType
style TemplateStyle style TemplateStyle
@@ -159,8 +161,11 @@ model Action {
model Campaign { model Campaign {
id String @id @default(uuid()) id String @id @default(uuid())
subject String subject String
body String body String
email String?
from String?
status CampaignStatus @default(DRAFT) status CampaignStatus @default(DRAFT)
delivered DateTime? delivered DateTime?
+1648 -1533
View File
File diff suppressed because it is too large Load Diff