Add unsend campaign feature (#45)

* Add unsend email editor

Add email editor

Add more email editor

Add renderer partial

Add more marketing email features

* Add more campaign feature

* Add variables

* Getting there

* campaign is there mfs

* Add migration
This commit is contained in:
KM Koushik
2024-08-10 10:09:10 +10:00
committed by GitHub
parent 0c072579b9
commit 5ddc0a7bb9
92 changed files with 11766 additions and 338 deletions

View File

@@ -20,7 +20,6 @@
"@auth/prisma-adapter": "^1.4.0",
"@aws-sdk/client-sesv2": "^3.535.0",
"@aws-sdk/client-sns": "^3.540.0",
"@hono/node-server": "^1.9.1",
"@hono/swagger-ui": "^0.2.1",
"@hono/zod-openapi": "^0.10.0",
"@hookform/resolvers": "^3.3.4",
@@ -32,6 +31,7 @@
"@trpc/next": "next",
"@trpc/react-query": "next",
"@trpc/server": "next",
"@unsend/email-editor": "workspace:*",
"@unsend/ui": "workspace:*",
"bullmq": "^5.8.2",
"date-fns": "^3.6.0",
@@ -56,6 +56,7 @@
"tldts": "^6.1.16",
"ua-parser-js": "^1.0.38",
"unsend": "workspace:*",
"use-debounce": "^10.0.2",
"zod": "^3.22.4"
},
"devDependencies": {

View File

@@ -0,0 +1,81 @@
-- CreateEnum
CREATE TYPE "CampaignStatus" AS ENUM ('DRAFT', 'SCHEDULED', 'SENT');
-- AlterTable
ALTER TABLE "Email" ADD COLUMN "campaignId" TEXT,
ADD COLUMN "contactId" TEXT;
-- AlterTable
ALTER TABLE "SesSetting" ADD COLUMN "transactionalQuota" INTEGER NOT NULL DEFAULT 50;
-- CreateTable
CREATE TABLE "ContactBook" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"teamId" INTEGER NOT NULL,
"properties" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ContactBook_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Contact" (
"id" TEXT NOT NULL,
"firstName" TEXT,
"lastName" TEXT,
"email" TEXT NOT NULL,
"subscribed" BOOLEAN NOT NULL DEFAULT true,
"properties" JSONB NOT NULL,
"contactBookId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Contact_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Campaign" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"teamId" INTEGER NOT NULL,
"from" TEXT NOT NULL,
"cc" TEXT[],
"bcc" TEXT[],
"replyTo" TEXT[],
"domainId" INTEGER NOT NULL,
"subject" TEXT NOT NULL,
"previewText" TEXT,
"html" TEXT,
"content" TEXT,
"contactBookId" TEXT,
"total" INTEGER NOT NULL DEFAULT 0,
"sent" INTEGER NOT NULL DEFAULT 0,
"delivered" INTEGER NOT NULL DEFAULT 0,
"opened" INTEGER NOT NULL DEFAULT 0,
"clicked" INTEGER NOT NULL DEFAULT 0,
"unsubscribed" INTEGER NOT NULL DEFAULT 0,
"bounced" INTEGER NOT NULL DEFAULT 0,
"complained" INTEGER NOT NULL DEFAULT 0,
"status" "CampaignStatus" NOT NULL DEFAULT 'DRAFT',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Campaign_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ContactBook_teamId_idx" ON "ContactBook"("teamId");
-- CreateIndex
CREATE UNIQUE INDEX "Contact_contactBookId_email_key" ON "Contact"("contactBookId", "email");
-- AddForeignKey
ALTER TABLE "ContactBook" ADD CONSTRAINT "ContactBook_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Contact" ADD CONSTRAINT "Contact_contactBookId_fkey" FOREIGN KEY ("contactBookId") REFERENCES "ContactBook"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Campaign" ADD CONSTRAINT "Campaign_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -2,7 +2,7 @@
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
previewFeatures = ["tracing"]
}
@@ -26,6 +26,7 @@ model SesSetting {
idPrefix String @unique
topic String
topicArn String?
transactionalQuota Int @default(50)
callbackUrl String
callbackSuccess Boolean @default(false)
configGeneral String?
@@ -90,14 +91,16 @@ model User {
}
model Team {
id Int @id @default(autoincrement())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
teamUsers TeamUser[]
domains Domain[]
apiKeys ApiKey[]
emails Email[]
id Int @id @default(autoincrement())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
teamUsers TeamUser[]
domains Domain[]
apiKeys ApiKey[]
emails Email[]
contactBooks ContactBook[]
campaigns Campaign[]
}
enum Role {
@@ -193,6 +196,8 @@ model Email {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
attachments String?
campaignId String?
contactId String?
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
emailEvents EmailEvent[]
}
@@ -205,3 +210,67 @@ model EmailEvent {
createdAt DateTime @default(now())
email Email @relation(fields: [emailId], references: [id], onDelete: Cascade)
}
model ContactBook {
id String @id @default(cuid())
name String
teamId Int
properties Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contacts Contact[]
@@index([teamId])
}
model Contact {
id String @id @default(cuid())
firstName String?
lastName String?
email String
subscribed Boolean @default(true)
properties Json
contactBookId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contactBook ContactBook @relation(fields: [contactBookId], references: [id], onDelete: Cascade)
@@unique([contactBookId, email])
}
enum CampaignStatus {
DRAFT
SCHEDULED
SENT
}
model Campaign {
id String @id @default(cuid())
name String
teamId Int
from String
cc String[]
bcc String[]
replyTo String[]
domainId Int
subject String
previewText String?
html String?
content String?
contactBookId String?
total Int @default(0)
sent Int @default(0)
delivered Int @default(0)
opened Int @default(0)
clicked Int @default(0)
unsubscribed Int @default(0)
bounced Int @default(0)
complained Int @default(0)
status CampaignStatus @default(DRAFT)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
}

View File

@@ -0,0 +1,168 @@
"use client";
import { Button } from "@unsend/ui/src/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@unsend/ui/src/dialog";
import { Edit } from "lucide-react";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@unsend/ui/src/form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { api } from "~/trpc/react";
import { Input } from "@unsend/ui/src/input";
import { toast } from "@unsend/ui/src/toaster";
import Spinner from "@unsend/ui/src/spinner";
import { SesSetting } from "@prisma/client";
const FormSchema = z.object({
settingsId: z.string(),
sendRate: z.preprocess((val) => Number(val), z.number()),
transactionalQuota: z.preprocess(
(val) => Number(val),
z.number().min(0).max(100)
),
});
export default function EditSesConfiguration({
setting,
}: {
setting: SesSetting;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog
open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit SES configuration</DialogTitle>
</DialogHeader>
<div className="py-2">
<EditSesSettingsForm
setting={setting}
onSuccess={() => setOpen(false)}
/>
</div>
</DialogContent>
</Dialog>
);
}
type SesSettingsProps = {
setting: SesSetting;
onSuccess?: () => void;
};
export const EditSesSettingsForm: React.FC<SesSettingsProps> = ({
setting,
onSuccess,
}) => {
const updateSesSettings = api.admin.updateSesSettings.useMutation();
const utils = api.useUtils();
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
settingsId: setting.id,
sendRate: setting.sesEmailRateLimit,
transactionalQuota: setting.transactionalQuota,
},
});
function onSubmit(data: z.infer<typeof FormSchema>) {
updateSesSettings.mutate(data, {
onSuccess: () => {
utils.admin.invalidate();
onSuccess?.();
},
onError: (e) => {
toast.error("Failed to update", {
description: e.message,
});
},
});
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className=" flex flex-col gap-8 w-full"
>
<FormField
control={form.control}
name="sendRate"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Send Rate</FormLabel>
<FormControl>
<Input placeholder="1" className="w-full" {...field} />
</FormControl>
{formState.errors.sendRate ? (
<FormMessage />
) : (
<FormDescription>
The number of emails to send per second.
</FormDescription>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="transactionalQuota"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Transactional Quota</FormLabel>
<FormControl>
<Input placeholder="0" className="w-full" {...field} />
</FormControl>
{formState.errors.transactionalQuota ? (
<FormMessage />
) : (
<FormDescription>
The percentage of the quota to be used for transactional
emails (0-100%).
</FormDescription>
)}
</FormItem>
)}
/>
<Button
type="submit"
disabled={updateSesSettings.isPending}
className="w-[200px] mx-auto"
>
{updateSesSettings.isPending ? (
<Spinner className="w-5 h-5" />
) : (
"Update"
)}
</Button>
</form>
</Form>
);
};

View File

@@ -11,6 +11,8 @@ import {
import { formatDistanceToNow } from "date-fns";
import { api } from "~/trpc/react";
import Spinner from "@unsend/ui/src/spinner";
import EditSesConfiguration from "./edit-ses-configuration";
import { TextWithCopyButton } from "@unsend/ui/src/text-with-copy";
export default function SesConfigurations() {
const sesSettingsQuery = api.admin.getSesSettings.useQuery();
@@ -25,6 +27,9 @@ export default function SesConfigurations() {
<TableHead>Callback URL</TableHead>
<TableHead>Callback status</TableHead>
<TableHead>Created at</TableHead>
<TableHead>Send rate</TableHead>
<TableHead>Transactional quota</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -47,13 +52,25 @@ export default function SesConfigurations() {
sesSettingsQuery.data?.map((sesSetting) => (
<TableRow key={sesSetting.id}>
<TableCell>{sesSetting.region}</TableCell>
<TableCell>{sesSetting.callbackUrl}</TableCell>
<TableCell>
<div className="w-[200px] overflow-hidden text-ellipsis">
<TextWithCopyButton
value={sesSetting.callbackUrl}
className="w-[200px] overflow-hidden text-ellipsis"
/>
</div>
</TableCell>
<TableCell>
{sesSetting.callbackSuccess ? "Success" : "Failed"}
</TableCell>
<TableCell>
{formatDistanceToNow(sesSetting.createdAt)} ago
</TableCell>
<TableCell>{sesSetting.sesEmailRateLimit}</TableCell>
<TableCell>{sesSetting.transactionalQuota}%</TableCell>
<TableCell>
<EditSesConfiguration setting={sesSetting} />
</TableCell>
</TableRow>
))
)}

View File

@@ -0,0 +1,347 @@
"use client";
import { api } from "~/trpc/react";
import { useInterval } from "~/hooks/useInterval";
import { Spinner } from "@unsend/ui/src/spinner";
import { Button } from "@unsend/ui/src/button";
import { Input } from "@unsend/ui/src/input";
import { Editor } from "@unsend/email-editor";
import { useState } from "react";
import { Campaign } from "@prisma/client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@unsend/ui/src/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@unsend/ui/src/dialog";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@unsend/ui/src/form";
import { toast } from "@unsend/ui/src/toaster";
import { useDebouncedCallback } from "use-debounce";
import { formatDistanceToNow } from "date-fns";
const sendSchema = z.object({
confirmation: z.string(),
});
export default function EditCampaignPage({
params,
}: {
params: { campaignId: string };
}) {
const {
data: campaign,
isLoading,
error,
} = api.campaign.getCampaign.useQuery(
{ campaignId: params.campaignId },
{
enabled: !!params.campaignId,
}
);
if (isLoading) {
return (
<div className="flex justify-center items-center h-full">
<Spinner className="w-6 h-6" />
</div>
);
}
if (error) {
return (
<div className="flex justify-center items-center h-full">
<p className="text-red-500">Failed to load campaign</p>
</div>
);
}
if (!campaign) {
return <div>Campaign not found</div>;
}
return <CampaignEditor campaign={campaign} />;
}
function CampaignEditor({ campaign }: { campaign: Campaign }) {
const contactBooksQuery = api.contacts.getContactBooks.useQuery();
const utils = api.useUtils();
const [json, setJson] = useState<Record<string, any> | undefined>(
campaign.content ? JSON.parse(campaign.content) : undefined
);
const [isSaving, setIsSaving] = useState(false);
const [name, setName] = useState(campaign.name);
const [subject, setSubject] = useState(campaign.subject);
const [from, setFrom] = useState(campaign.from);
const [contactBookId, setContactBookId] = useState(campaign.contactBookId);
const [openSendDialog, setOpenSendDialog] = useState(false);
const updateCampaignMutation = api.campaign.updateCampaign.useMutation({
onSuccess: () => {
utils.campaign.getCampaign.invalidate();
setIsSaving(false);
},
});
const sendCampaignMutation = api.campaign.sendCampaign.useMutation();
const sendForm = useForm<z.infer<typeof sendSchema>>({
resolver: zodResolver(sendSchema),
});
function updateEditorContent() {
updateCampaignMutation.mutate({
campaignId: campaign.id,
content: JSON.stringify(json),
});
}
const deboucedUpdateCampaign = useDebouncedCallback(
updateEditorContent,
1000
);
async function onSendCampaign(values: z.infer<typeof sendSchema>) {
if (
values.confirmation?.toLocaleLowerCase() !== "Send".toLocaleLowerCase()
) {
sendForm.setError("confirmation", {
message: "Please type 'Send' to confirm",
});
return;
}
sendCampaignMutation.mutate(
{
campaignId: campaign.id,
},
{
onSuccess: () => {
setOpenSendDialog(false);
toast.success(`Campaign sent successfully`);
},
onError: (error) => {
toast.error(`Failed to send campaign: ${error.message}`);
},
}
);
}
const confirmation = sendForm.watch("confirmation");
return (
<div className="p-4">
<div className="w-[600px] mx-auto">
<div className="mb-4 flex justify-between items-center">
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className=" border-0 focus:ring-0 focus:outline-none px-0.5 w-[300px]"
onBlur={() => {
if (name === campaign.name || !name) {
return;
}
updateCampaignMutation.mutate(
{
campaignId: campaign.id,
name,
},
{
onError: (e) => {
toast.error(`${e.message}. Reverting changes.`);
setName(campaign.name);
},
}
);
}}
/>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
{isSaving ? (
<div className="h-2 w-2 bg-yellow-500 rounded-full" />
) : (
<div className="h-2 w-2 bg-emerald-500 rounded-full" />
)}
{formatDistanceToNow(campaign.updatedAt) === "less than a minute"
? "just now"
: `${formatDistanceToNow(campaign.updatedAt)} ago`}
</div>
<Dialog open={openSendDialog} onOpenChange={setOpenSendDialog}>
<DialogTrigger asChild>
<Button variant="default">Send Campaign</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Send Campaign</DialogTitle>
<DialogDescription>
Are you sure you want to send this campaign? This action
cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="py-2">
<Form {...sendForm}>
<form
onSubmit={sendForm.handleSubmit(onSendCampaign)}
className="space-y-4"
>
<FormField
control={sendForm.control}
name="confirmation"
render={({ field }) => (
<FormItem>
<FormLabel>Type 'Send' to confirm</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
disabled={
sendCampaignMutation.isPending ||
confirmation?.toLocaleLowerCase() !==
"Send".toLocaleLowerCase()
}
>
{sendCampaignMutation.isPending
? "Sending..."
: "Send"}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
</div>
</div>
<div className="mb-4 mt-8">
<label className="block text-sm font-medium ">Subject</label>
<Input
type="text"
value={subject}
onChange={(e) => {
setSubject(e.target.value);
}}
onBlur={() => {
if (subject === campaign.subject || !subject) {
return;
}
updateCampaignMutation.mutate(
{
campaignId: campaign.id,
subject,
},
{
onError: (e) => {
toast.error(`${e.message}. Reverting changes.`);
setSubject(campaign.subject);
},
}
);
}}
className="mt-1 block w-full rounded-md shadow-sm"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium ">From</label>
<Input
type="text"
value={from}
onChange={(e) => {
setFrom(e.target.value);
}}
className="mt-1 block w-full rounded-md shadow-sm"
placeholder="Friendly name<hello@example.com>"
onBlur={() => {
if (from === campaign.from) {
return;
}
updateCampaignMutation.mutate(
{
campaignId: campaign.id,
from,
},
{
onError: (e) => {
toast.error(`${e.message}. Reverting changes.`);
setFrom(campaign.from);
},
}
);
}}
/>
</div>
<div className="mb-12">
<label className="block text-sm font-medium mb-1">To</label>
{contactBooksQuery.isLoading ? (
<Spinner className="w-6 h-6" />
) : (
<Select
value={contactBookId ?? ""}
onValueChange={(val) => {
// Update the campaign's contactBookId
updateCampaignMutation.mutate(
{
campaignId: campaign.id,
contactBookId: val,
},
{
onError: () => {
setContactBookId(campaign.contactBookId);
},
}
);
setContactBookId(val);
}}
>
<SelectTrigger className="w-[300px]">
{contactBooksQuery.data?.find(
(book) => book.id === contactBookId
)?.name || "Select a contact book"}
</SelectTrigger>
<SelectContent>
{contactBooksQuery.data?.map((book) => (
<SelectItem key={book.id} value={book.id}>
{book.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<Editor
initialContent={json}
onUpdate={(content) => {
setJson(content.getJSON());
setIsSaving(true);
deboucedUpdateCampaign();
}}
variables={["email", "firstName", "lastName"]}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,194 @@
"use client";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@unsend/ui/src/breadcrumb";
import Link from "next/link";
import Spinner from "@unsend/ui/src/spinner";
import { formatDistanceToNow } from "date-fns";
import { api } from "~/trpc/react";
import { EmailStatusIcon } from "../../emails/email-status-badge";
import { EmailStatus } from "@prisma/client";
import { Separator } from "@unsend/ui/src/separator";
import { ExternalLinkIcon } from "lucide-react";
export default function CampaignDetailsPage({
params,
}: {
params: { campaignId: string };
}) {
const { data: campaign, isLoading } = api.campaign.getCampaign.useQuery({
campaignId: params.campaignId,
});
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen">
<Spinner className="w-5 h-5 text-primary" />
</div>
);
}
if (!campaign) {
return <div>Campaign not found</div>;
}
const statusCards = [
{
status: "delivered",
count: campaign.delivered,
percentage: 100,
},
{
status: "unsubscribed",
count: campaign.unsubscribed,
percentage: (campaign.unsubscribed / campaign.delivered) * 100,
},
{
status: "clicked",
count: campaign.clicked,
percentage: (campaign.clicked / campaign.delivered) * 100,
},
{
status: "opened",
count: campaign.opened,
percentage: (campaign.opened / campaign.delivered) * 100,
},
];
return (
<div className="container mx-auto py-8">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/campaigns" className="text-lg">
Campaigns
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="text-lg" />
<BreadcrumbItem>
<BreadcrumbPage className="text-lg ">
{campaign.name}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className=" rounded-lg shadow mt-10">
<h2 className="text-xl font-semibold mb-4"> Statistics</h2>
<div className="flex gap-4">
{statusCards.map((card) => (
<div
key={card.status}
className="h-[100px] w-1/4 bg-secondary/10 border rounded-lg p-4 flex flex-col gap-3"
>
<div className="flex items-center gap-3">
{card.status !== "total" ? (
<CampaignStatusBadge status={card.status} />
) : null}
<div className="capitalize">{card.status.toLowerCase()}</div>
</div>
<div className="flex justify-between items-end">
<div className="text-primary font-light text-2xl font-mono">
{card.count}
</div>
{card.status !== "total" ? (
<div className="text-sm pb-1">
{card.percentage.toFixed(1)}%
</div>
) : null}
</div>
</div>
))}
</div>
</div>
{campaign.html && (
<div className=" rounded-lg shadow mt-16">
<h2 className="text-xl font-semibold mb-4">Email</h2>
<div className="p-2 rounded-lg border flex flex-col gap-4 w-full">
<div className="flex gap-2 mt-2">
<span className="w-[65px] text-muted-foreground ">From</span>
<span>{campaign.from}</span>
</div>
<Separator />
<div className="flex gap-2">
<span className="w-[65px] text-muted-foreground ">To</span>
{campaign.contactBookId ? (
<Link
href={`/contacts/${campaign.contactBookId}`}
className="text-primary px-4 p-1 bg-muted text-sm rounded-md flex gap-1 items-center"
target="_blank"
>
{campaign.contactBook?.name}
<ExternalLinkIcon className="w-4 h-4 " />
</Link>
) : (
<div>No one</div>
)}
</div>
<Separator />
<div className="flex gap-2">
<span className="w-[65px] text-muted-foreground ">Subject</span>
<span>{campaign.subject}</span>
</div>
<div className=" dark:bg-slate-50 overflow-auto text-black rounded py-8">
<div dangerouslySetInnerHTML={{ __html: campaign.html ?? "" }} />
</div>
</div>
</div>
)}
</div>
);
}
export const CampaignStatusBadge: React.FC<{ status: string }> = ({
status,
}) => {
let outsideColor = "bg-gray-600";
let insideColor = "bg-gray-600/50";
switch (status) {
case "delivered":
outsideColor = "bg-emerald-500/30";
insideColor = "bg-emerald-500";
break;
case "bounced":
case "unsubscribed":
outsideColor = "bg-red-500/30";
insideColor = "bg-red-500";
break;
case "clicked":
outsideColor = "bg-cyan-500/30";
insideColor = "bg-cyan-500";
break;
case "opened":
outsideColor = "bg-indigo-500/30";
insideColor = "bg-indigo-500";
break;
case "complained":
outsideColor = "bg-yellow-500/30";
insideColor = "bg-yellow-500";
break;
default:
outsideColor = "bg-gray-600/40";
insideColor = "bg-gray-600";
}
return (
<div
className={`flex justify-center items-center p-1.5 ${outsideColor} rounded-full`}
>
<div className={`h-2 w-2 rounded-full ${insideColor}`}></div>
</div>
);
};

View File

@@ -0,0 +1,150 @@
"use client";
import {
Table,
TableHeader,
TableRow,
TableHead,
TableBody,
TableCell,
} from "@unsend/ui/src/table";
import { api } from "~/trpc/react";
import { useUrlState } from "~/hooks/useUrlState";
import { Button } from "@unsend/ui/src/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@unsend/ui/src/select";
import Spinner from "@unsend/ui/src/spinner";
import { formatDistanceToNow } from "date-fns";
import { CampaignStatus } from "@prisma/client";
import DeleteCampaign from "./delete-campaign";
import { Edit2 } from "lucide-react";
import Link from "next/link";
import DuplicateCampaign from "./duplicate-campaign";
export default function CampaignList() {
const [page, setPage] = useUrlState("page", "1");
const [status, setStatus] = useUrlState("status");
const pageNumber = Number(page);
const campaignsQuery = api.campaign.getCampaigns.useQuery({
page: pageNumber,
});
return (
<div className="mt-10 flex flex-col gap-4">
<div className="flex justify-end">
{/* <Select
value={status ?? "All"}
onValueChange={(val) => setStatus(val === "All" ? null : val)}
>
<SelectTrigger className="w-[180px] capitalize">
{status || "All statuses"}
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className=" capitalize">
All statuses
</SelectItem>
<SelectItem value="Active" className=" capitalize">
Active
</SelectItem>
<SelectItem value="Inactive" className=" capitalize">
Inactive
</SelectItem>
</SelectContent>
</Select> */}
</div>
<div className="flex flex-col rounded-xl border border-border shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
<TableHead className="rounded-tl-xl">Name</TableHead>
<TableHead>Status</TableHead>
<TableHead className="">Created At</TableHead>
<TableHead className="rounded-tr-xl">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{campaignsQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<Spinner
className="w-6 h-6 mx-auto"
innerSvgClass="stroke-primary"
/>
</TableCell>
</TableRow>
) : campaignsQuery.data?.campaigns.length ? (
campaignsQuery.data?.campaigns.map((campaign) => (
<TableRow key={campaign.id} className="">
<TableCell className="font-medium">
<Link
className="underline underline-offset-4 decoration-dashed text-foreground hover:text-primary"
href={
campaign.status === CampaignStatus.DRAFT
? `/campaigns/${campaign.id}/edit`
: `/campaigns/${campaign.id}`
}
>
{campaign.name}
</Link>
</TableCell>
<TableCell>
<div
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
campaign.status === CampaignStatus.DRAFT
? "bg-gray-500/10 text-gray-500 border-gray-600/10"
: campaign.status === CampaignStatus.SENT
? "bg-emerald-500/10 text-emerald-500 border-emerald-600/10"
: "bg-yellow-500/10 text-yellow-600 border-yellow-600/10"
}`}
>
{campaign.status.toLowerCase()}
</div>
</TableCell>
<TableCell className="">
{formatDistanceToNow(new Date(campaign.createdAt), {
addSuffix: true,
})}
</TableCell>
<TableCell>
<div className="flex gap-2">
<DuplicateCampaign campaign={campaign} />
<DeleteCampaign campaign={campaign} />
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
No campaigns found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex gap-4 justify-end">
<Button
size="sm"
onClick={() => setPage((pageNumber - 1).toString())}
disabled={pageNumber === 1}
>
Previous
</Button>
<Button
size="sm"
onClick={() => setPage((pageNumber + 1).toString())}
disabled={pageNumber >= (campaignsQuery.data?.totalPage ?? 0)}
>
Next
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,166 @@
"use client";
import { Button } from "@unsend/ui/src/button";
import { Input } from "@unsend/ui/src/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@unsend/ui/src/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@unsend/ui/src/form";
import { api } from "~/trpc/react";
import { useState } from "react";
import { Plus } from "lucide-react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@unsend/ui/src/toaster";
import { useRouter } from "next/navigation";
import Spinner from "@unsend/ui/src/spinner";
const campaignSchema = z.object({
name: z.string({ required_error: "Name is required" }).min(1, {
message: "Name is required",
}),
from: z.string({ required_error: "From email is required" }).min(1, {
message: "From email is required",
}),
subject: z.string({ required_error: "Subject is required" }).min(1, {
message: "Subject is required",
}),
});
export default function CreateCampaign() {
const router = useRouter();
const [open, setOpen] = useState(false);
const createCampaignMutation = api.campaign.createCampaign.useMutation();
const campaignForm = useForm<z.infer<typeof campaignSchema>>({
resolver: zodResolver(campaignSchema),
defaultValues: {
name: "",
from: "",
subject: "",
},
});
const utils = api.useUtils();
async function onCampaignCreate(values: z.infer<typeof campaignSchema>) {
createCampaignMutation.mutate(
{
name: values.name,
from: values.from,
subject: values.subject,
},
{
onSuccess: async (data) => {
utils.campaign.getCampaigns.invalidate();
router.push(`/campaigns/${data.id}/edit`);
toast.success("Campaign created successfully");
setOpen(false);
},
onError: async (error) => {
toast.error(error.message);
},
}
);
}
return (
<Dialog
open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-1" />
Create Campaign
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create new campaign</DialogTitle>
</DialogHeader>
<div className="py-2">
<Form {...campaignForm}>
<form
onSubmit={campaignForm.handleSubmit(onCampaignCreate)}
className="space-y-8"
>
<FormField
control={campaignForm.control}
name="name"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Campaign Name" {...field} />
</FormControl>
{formState.errors.name ? <FormMessage /> : null}
</FormItem>
)}
/>
<FormField
control={campaignForm.control}
name="from"
render={({ field, formState }) => (
<FormItem>
<FormLabel>From</FormLabel>
<FormControl>
<Input
placeholder="Friendly Name <from@example.com>"
{...field}
/>
</FormControl>
{formState.errors.from ? <FormMessage /> : null}
</FormItem>
)}
/>
<FormField
control={campaignForm.control}
name="subject"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Subject</FormLabel>
<FormControl>
<Input placeholder="Campaign Subject" {...field} />
</FormControl>
{formState.errors.subject ? <FormMessage /> : null}
</FormItem>
)}
/>
<p className="text-muted-foreground text-sm">
Don't worry, you can change it later.
</p>
<div className="flex justify-end">
<Button
className=" w-[100px]"
type="submit"
disabled={createCampaignMutation.isPending}
>
{createCampaignMutation.isPending ? (
<Spinner className="w-4 h-4" />
) : (
"Create"
)}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { Button } from "@unsend/ui/src/button";
import { Input } from "@unsend/ui/src/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@unsend/ui/src/dialog";
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { toast } from "@unsend/ui/src/toaster";
import { Trash2 } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@unsend/ui/src/form";
import { Campaign } from "@prisma/client";
const campaignSchema = z.object({
name: z.string(),
});
export const DeleteCampaign: React.FC<{
campaign: Partial<Campaign> & { id: string };
}> = ({ campaign }) => {
const [open, setOpen] = useState(false);
const deleteCampaignMutation = api.campaign.deleteCampaign.useMutation();
const utils = api.useUtils();
const campaignForm = useForm<z.infer<typeof campaignSchema>>({
resolver: zodResolver(campaignSchema),
});
async function onCampaignDelete(values: z.infer<typeof campaignSchema>) {
if (values.name !== campaign.name) {
campaignForm.setError("name", {
message: "Name does not match",
});
return;
}
deleteCampaignMutation.mutate(
{
campaignId: campaign.id,
},
{
onSuccess: () => {
utils.campaign.getCampaigns.invalidate();
setOpen(false);
toast.success(`Campaign deleted`);
},
}
);
}
const name = campaignForm.watch("name");
return (
<Dialog
open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red-600/80" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Campaign</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-primary">{campaign.name}</span>?
You can't reverse this.
</DialogDescription>
</DialogHeader>
<div className="py-2">
<Form {...campaignForm}>
<form
onSubmit={campaignForm.handleSubmit(onCampaignDelete)}
className="space-y-4"
>
<FormField
control={campaignForm.control}
name="name"
render={({ field, formState }) => (
<FormItem>
<FormLabel>name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
{formState.errors.name ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
.
</FormDescription>
)}
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
variant="destructive"
disabled={
deleteCampaignMutation.isPending || campaign.name !== name
}
>
{deleteCampaignMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
};
export default DeleteCampaign;

View File

@@ -0,0 +1,78 @@
"use client";
import { Button } from "@unsend/ui/src/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@unsend/ui/src/dialog";
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { toast } from "@unsend/ui/src/toaster";
import { Copy } from "lucide-react";
import { Campaign } from "@prisma/client";
export const DuplicateCampaign: React.FC<{
campaign: Partial<Campaign> & { id: string };
}> = ({ campaign }) => {
const [open, setOpen] = useState(false);
const duplicateCampaignMutation =
api.campaign.duplicateCampaign.useMutation();
const utils = api.useUtils();
async function onCampaignDuplicate() {
duplicateCampaignMutation.mutate(
{
campaignId: campaign.id,
},
{
onSuccess: () => {
utils.campaign.getCampaigns.invalidate();
setOpen(false);
toast.success(`Campaign duplicated`);
},
}
);
}
return (
<Dialog
open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Copy className="h-4 w-4 text-blue-600/80" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Duplicate Campaign</DialogTitle>
<DialogDescription>
Are you sure you want to duplicate{" "}
<span className="font-semibold text-primary">{campaign.name}</span>?
</DialogDescription>
</DialogHeader>
<div className="py-2">
<div className="flex justify-end">
<Button
onClick={onCampaignDuplicate}
variant="default"
disabled={duplicateCampaignMutation.isPending}
>
{duplicateCampaignMutation.isPending
? "Duplicating..."
: "Duplicate"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
export default DuplicateCampaign;

View File

@@ -0,0 +1,16 @@
"use client";
import CampaignList from "./campaign-list";
import CreateCampaign from "./create-campaign";
export default function ContactsPage() {
return (
<div>
<div className="flex justify-between items-center">
<h1 className="font-bold text-lg">Campaigns</h1>
<CreateCampaign />
</div>
<CampaignList />
</div>
);
}

View File

@@ -0,0 +1,136 @@
"use client";
import { Button } from "@unsend/ui/src/button";
import { Textarea } from "@unsend/ui/src/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@unsend/ui/src/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@unsend/ui/src/form";
import { api } from "~/trpc/react";
import { useState } from "react";
import { Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@unsend/ui/src/toaster";
const contactsSchema = z.object({
contacts: z.string({ required_error: "Contacts are required" }).min(1, {
message: "Contacts are required",
}),
});
export default function AddContact({
contactBookId,
}: {
contactBookId: string;
}) {
const [open, setOpen] = useState(false);
const addContactsMutation = api.contacts.addContacts.useMutation();
const contactsForm = useForm<z.infer<typeof contactsSchema>>({
resolver: zodResolver(contactsSchema),
defaultValues: {
contacts: "",
},
});
const utils = api.useUtils();
async function onContactsAdd(values: z.infer<typeof contactsSchema>) {
const contactsArray = values.contacts.split(",").map((email) => ({
email: email.trim(),
}));
addContactsMutation.mutate(
{
contactBookId,
contacts: contactsArray,
},
{
onSuccess: async () => {
utils.contacts.contacts.invalidate();
setOpen(false);
toast.success("Contacts added successfully");
},
onError: async (error) => {
toast.error(error.message);
},
}
);
}
return (
<Dialog
open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-1" />
Add Contacts
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add new contacts</DialogTitle>
</DialogHeader>
<div className="py-2">
<Form {...contactsForm}>
<form
onSubmit={contactsForm.handleSubmit(onContactsAdd)}
className="space-y-8"
>
<FormField
control={contactsForm.control}
name="contacts"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Contacts</FormLabel>
<FormControl>
<Textarea
placeholder="email1@example.com, email2@example.com"
{...field}
/>
</FormControl>
{formState.errors.contacts ? (
<FormMessage />
) : (
<FormDescription>
Enter comma-separated email addresses.
</FormDescription>
)}
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
type="submit"
disabled={addContactsMutation.isPending}
>
{addContactsMutation.isPending ? "Adding..." : "Add"}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,145 @@
"use client";
import {
Table,
TableHeader,
TableRow,
TableHead,
TableBody,
TableCell,
} from "@unsend/ui/src/table";
import { api } from "~/trpc/react";
import { useUrlState } from "~/hooks/useUrlState";
import { Button } from "@unsend/ui/src/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@unsend/ui/src/select";
import Spinner from "@unsend/ui/src/spinner";
import { formatDistanceToNow } from "date-fns";
import DeleteContact from "./delete-contact";
import EditContact from "./edit-contact";
export default function ContactList({
contactBookId,
}: {
contactBookId: string;
}) {
const [page, setPage] = useUrlState("page", "1");
const [status, setStatus] = useUrlState("status");
const pageNumber = Number(page);
const contactsQuery = api.contacts.contacts.useQuery({
contactBookId,
page: pageNumber,
subscribed:
status === "Subscribed"
? true
: status === "Unsubscribed"
? false
: undefined,
});
return (
<div className="mt-10 flex flex-col gap-4">
<div className="flex justify-end">
<Select
value={status ?? "All"}
onValueChange={(val) => setStatus(val === "All" ? null : val)}
>
<SelectTrigger className="w-[180px] capitalize">
{status || "All statuses"}
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className=" capitalize">
All statuses
</SelectItem>
<SelectItem value="Subscribed" className=" capitalize">
Subscribed
</SelectItem>
<SelectItem value="Unsubscribed" className=" capitalize">
Unsubscribed
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col rounded-xl border border-broder shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
<TableHead className="rounded-tl-xl">Email</TableHead>
<TableHead>Status</TableHead>
<TableHead className="">Created At</TableHead>
<TableHead className="rounded-tr-xl">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{contactsQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<Spinner
className="w-6 h-6 mx-auto"
innerSvgClass="stroke-primary"
/>
</TableCell>
</TableRow>
) : contactsQuery.data?.contacts.length ? (
contactsQuery.data?.contacts.map((contact) => (
<TableRow key={contact.id} className="">
<TableCell className="font-medium">{contact.email}</TableCell>
<TableCell>
<div
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
contact.subscribed
? "bg-emerald-500/10 text-emerald-500 border-emerald-600/10"
: "bg-red-500/10 text-red-600 border-red-600/10"
}`}
>
{contact.subscribed ? "Subscribed" : "Unsubscribed"}
</div>
</TableCell>
<TableCell className="">
{formatDistanceToNow(new Date(contact.createdAt), {
addSuffix: true,
})}
</TableCell>
<TableCell>
<div className="flex gap-2">
<EditContact contact={contact} />
<DeleteContact contact={contact} />
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
No contacts found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex gap-4 justify-end">
<Button
size="sm"
onClick={() => setPage((pageNumber - 1).toString())}
disabled={pageNumber === 1}
>
Previous
</Button>
<Button
size="sm"
onClick={() => setPage((pageNumber + 1).toString())}
disabled={pageNumber >= (contactsQuery.data?.totalPage ?? 0)}
>
Next
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,138 @@
"use client";
import { Button } from "@unsend/ui/src/button";
import { Input } from "@unsend/ui/src/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@unsend/ui/src/dialog";
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { toast } from "@unsend/ui/src/toaster";
import { Trash2 } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@unsend/ui/src/form";
import { Contact } from "@prisma/client";
const contactSchema = z.object({
email: z.string().email(),
});
export const DeleteContact: React.FC<{
contact: Partial<Contact> & { id: string; contactBookId: string };
}> = ({ contact }) => {
const [open, setOpen] = useState(false);
const deleteContactMutation = api.contacts.deleteContact.useMutation();
const utils = api.useUtils();
const contactForm = useForm<z.infer<typeof contactSchema>>({
resolver: zodResolver(contactSchema),
});
async function onContactDelete(values: z.infer<typeof contactSchema>) {
if (values.email !== contact.email) {
contactForm.setError("email", {
message: "Email does not match",
});
return;
}
deleteContactMutation.mutate(
{
contactId: contact.id,
contactBookId: contact.contactBookId,
},
{
onSuccess: () => {
utils.contacts.contacts.invalidate();
setOpen(false);
toast.success(`Contact deleted`);
},
onError: (e) => {
toast.error(`Contact not deleted: ${e.message}`);
},
}
);
}
const email = contactForm.watch("email");
return (
<Dialog
open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red-600/80" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Contact</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-primary">{contact.email}</span>?
You can't reverse this.
</DialogDescription>
</DialogHeader>
<div className="py-2">
<Form {...contactForm}>
<form
onSubmit={contactForm.handleSubmit(onContactDelete)}
className="space-y-4"
>
<FormField
control={contactForm.control}
name="email"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
{formState.errors.email ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
.
</FormDescription>
)}
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
variant="destructive"
disabled={
deleteContactMutation.isPending || contact.email !== email
}
>
{deleteContactMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
};
export default DeleteContact;

View File

@@ -0,0 +1,171 @@
"use client";
import { Button } from "@unsend/ui/src/button";
import { Input } from "@unsend/ui/src/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@unsend/ui/src/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@unsend/ui/src/form";
import { api } from "~/trpc/react";
import { useState } from "react";
import { Edit } from "lucide-react";
import { useRouter } from "next/navigation";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@unsend/ui/src/toaster";
import { Switch } from "@unsend/ui/src/switch";
import { Contact } from "@prisma/client";
const contactSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
firstName: z.string().optional(),
lastName: z.string().optional(),
subscribed: z.boolean().optional(),
});
export const EditContact: React.FC<{
contact: Partial<Contact> & { id: string; contactBookId: string };
}> = ({ contact }) => {
const [open, setOpen] = useState(false);
const updateContactMutation = api.contacts.updateContact.useMutation();
const utils = api.useUtils();
const router = useRouter();
const contactForm = useForm<z.infer<typeof contactSchema>>({
resolver: zodResolver(contactSchema),
defaultValues: {
email: contact.email || "",
firstName: contact.firstName || "",
lastName: contact.lastName || "",
subscribed: contact.subscribed || false,
},
});
async function onContactUpdate(values: z.infer<typeof contactSchema>) {
updateContactMutation.mutate(
{
contactId: contact.id,
contactBookId: contact.contactBookId,
...values,
},
{
onSuccess: async () => {
utils.contacts.contacts.invalidate();
setOpen(false);
toast.success("Contact updated successfully");
},
onError: async (error) => {
toast.error(error.message);
},
}
);
}
return (
<Dialog
open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Contact</DialogTitle>
</DialogHeader>
<div className="py-2">
<Form {...contactForm}>
<form
onSubmit={contactForm.handleSubmit(onContactUpdate)}
className="space-y-8"
>
<FormField
control={contactForm.control}
name="email"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="email@example.com" {...field} />
</FormControl>
{formState.errors.email ? <FormMessage /> : null}
</FormItem>
)}
/>
<FormField
control={contactForm.control}
name="firstName"
render={({ field, formState }) => (
<FormItem>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input placeholder="First Name" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={contactForm.control}
name="lastName"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input placeholder="Last Name" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={contactForm.control}
name="subscribed"
render={({ field }) => (
<FormItem className="fle flex-row gap-2">
<div>
<FormLabel>Subscribed</FormLabel>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className="data-[state=checked]:bg-emerald-500"
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
type="submit"
disabled={updateContactMutation.isPending}
>
{updateContactMutation.isPending ? "Updating..." : "Update"}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
};
export default EditContact;

View File

@@ -0,0 +1,96 @@
"use client";
import { api } from "~/trpc/react";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@unsend/ui/src/breadcrumb";
import Link from "next/link";
import { Button } from "@unsend/ui/src/button";
import { Plus } from "lucide-react";
import AddContact from "./add-contact";
import ContactList from "./contact-list";
import { TextWithCopyButton } from "@unsend/ui/src/text-with-copy";
import { formatDistanceToNow } from "date-fns";
export default function ContactsPage({
params,
}: {
params: { contactBookId: string };
}) {
const contactBookDetailQuery = api.contacts.getContactBookDetails.useQuery({
contactBookId: params.contactBookId,
});
return (
<div>
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/contacts" className="text-lg">
Contact books
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="text-lg" />
<BreadcrumbItem>
<BreadcrumbPage className="text-lg ">
{contactBookDetailQuery.data?.name}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="flex gap-4">
<AddContact contactBookId={params.contactBookId} />
</div>
</div>
<div className="mt-16">
<div className="flex justify-between">
<div>
<div className=" text-muted-foreground">Total Contacts</div>
<div className="text-xl mt-3">
{contactBookDetailQuery.data?.totalContacts !== undefined
? contactBookDetailQuery.data?.totalContacts
: "--"}
</div>
</div>
<div>
<div className="text-muted-foreground">Unsubscribed</div>
<div className="text-xl mt-3">
{contactBookDetailQuery.data?.unsubscribedContacts !== undefined
? contactBookDetailQuery.data?.unsubscribedContacts
: "--"}
</div>
</div>
<div>
<div className="text-muted-foreground">Created at</div>
<div className="text-xl mt-3">
{contactBookDetailQuery.data?.createdAt
? formatDistanceToNow(contactBookDetailQuery.data.createdAt, {
addSuffix: true,
})
: "--"}
</div>
</div>
<div>
<div className="text-muted-foreground">Contact book id</div>
<div className="border mt-3 px-3 rounded bg-muted/30 ">
<TextWithCopyButton value={params.contactBookId} alwaysShowCopy />
</div>
</div>
</div>
<div className="mt-16">
<ContactList contactBookId={params.contactBookId} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
"use client";
import { Button } from "@unsend/ui/src/button";
import { Input } from "@unsend/ui/src/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@unsend/ui/src/dialog";
import { api } from "~/trpc/react";
import { useState } from "react";
import { Plus } from "lucide-react";
import { toast } from "@unsend/ui/src/toaster";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@unsend/ui/src/form";
const contactBookSchema = z.object({
name: z.string({ required_error: "Name is required" }).min(1, {
message: "Name is required",
}),
});
export default function AddContactBook() {
const [open, setOpen] = useState(false);
const createContactBookMutation =
api.contacts.createContactBook.useMutation();
const utils = api.useUtils();
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
resolver: zodResolver(contactBookSchema),
defaultValues: {
name: "",
},
});
function handleSave(values: z.infer<typeof contactBookSchema>) {
createContactBookMutation.mutate(
{
name: values.name,
},
{
onSuccess: () => {
utils.contacts.getContactBooks.invalidate();
contactBookForm.reset();
setOpen(false);
toast.success("Contact book created successfully");
},
}
);
}
return (
<Dialog
open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-1" />
Add Contact Book
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create a new contact book</DialogTitle>
</DialogHeader>
<div className="py-2">
<Form {...contactBookForm}>
<form
onSubmit={contactBookForm.handleSubmit(handleSave)}
className="space-y-8"
>
<FormField
control={contactBookForm.control}
name="name"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Contact book name</FormLabel>
<FormControl>
<Input placeholder="My contacts" {...field} />
</FormControl>
{formState.errors.name ? (
<FormMessage />
) : (
<FormDescription>
eg: product / website / newsletter name
</FormDescription>
)}
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
type="submit"
disabled={createContactBookMutation.isPending}
>
{createContactBookMutation.isPending
? "Creating..."
: "Create"}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@unsend/ui/src/table";
import { formatDistanceToNow } from "date-fns";
import { api } from "~/trpc/react";
import Spinner from "@unsend/ui/src/spinner";
import DeleteContactBook from "./delete-contact-book";
import Link from "next/link";
import EditContactBook from "./edit-contact-book";
export default function ContactBooksList() {
const contactBooksQuery = api.contacts.getContactBooks.useQuery();
return (
<div className="mt-10">
<div className="border rounded-xl">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
<TableHead className="rounded-tl-xl">Name</TableHead>
<TableHead>Contacts</TableHead>
<TableHead>Created at</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{contactBooksQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={6} className="text-center py-4">
<Spinner
className="w-6 h-6 mx-auto"
innerSvgClass="stroke-primary"
/>
</TableCell>
</TableRow>
) : contactBooksQuery.data?.length === 0 ? (
<TableRow className="h-32">
<TableCell colSpan={6} className="text-center py-4">
<p>No contact books added</p>
</TableCell>
</TableRow>
) : (
contactBooksQuery.data?.map((contactBook) => (
<TableRow>
<TableHead scope="row">
<Link
href={`/contacts/${contactBook.id}`}
className="underline underline-offset-4 decoration-dashed text-foreground hover:text-primary"
>
{contactBook.name}
</Link>
</TableHead>
{/* <TableCell>{contactBook.name}</TableCell> */}
<TableCell>{contactBook._count.contacts}</TableCell>
<TableCell>
{formatDistanceToNow(contactBook.createdAt, {
addSuffix: true,
})}
</TableCell>
<TableCell>
<EditContactBook contactBook={contactBook} />
<DeleteContactBook contactBook={contactBook} />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,142 @@
"use client";
import { Button } from "@unsend/ui/src/button";
import { Input } from "@unsend/ui/src/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@unsend/ui/src/dialog";
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { toast } from "@unsend/ui/src/toaster";
import { Trash2 } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@unsend/ui/src/form";
import { ContactBook } from "@prisma/client";
const contactBookSchema = z.object({
name: z.string(),
});
export const DeleteContactBook: React.FC<{
contactBook: Partial<ContactBook> & { id: string };
}> = ({ contactBook }) => {
const [open, setOpen] = useState(false);
const deleteContactBookMutation =
api.contacts.deleteContactBook.useMutation();
const utils = api.useUtils();
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
resolver: zodResolver(contactBookSchema),
});
async function onContactBookDelete(
values: z.infer<typeof contactBookSchema>
) {
if (values.name !== contactBook.name) {
contactBookForm.setError("name", {
message: "Name does not match",
});
return;
}
deleteContactBookMutation.mutate(
{
contactBookId: contactBook.id,
},
{
onSuccess: () => {
utils.contacts.getContactBooks.invalidate();
setOpen(false);
toast.success(`Contact book deleted`);
},
}
);
}
const name = contactBookForm.watch("name");
return (
<Dialog
open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red-600/80" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Contact Book</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-primary">
{contactBook.name}
</span>
? You can't reverse this.
</DialogDescription>
</DialogHeader>
<div className="py-2">
<Form {...contactBookForm}>
<form
onSubmit={contactBookForm.handleSubmit(onContactBookDelete)}
className="space-y-4"
>
<FormField
control={contactBookForm.control}
name="name"
render={({ field, formState }) => (
<FormItem>
<FormLabel>name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
{formState.errors.name ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
.
</FormDescription>
)}
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
variant="destructive"
disabled={
deleteContactBookMutation.isPending ||
contactBook.name !== name
}
>
{deleteContactBookMutation.isPending
? "Deleting..."
: "Delete"}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
};
export default DeleteContactBook;

View File

@@ -0,0 +1,122 @@
"use client";
import { Button } from "@unsend/ui/src/button";
import { Input } from "@unsend/ui/src/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@unsend/ui/src/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@unsend/ui/src/form";
import { api } from "~/trpc/react";
import { useState } from "react";
import { Edit } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@unsend/ui/src/toaster";
const contactBookSchema = z.object({
name: z.string().min(1, { message: "Name is required" }),
});
export const EditContactBook: React.FC<{
contactBook: { id: string; name: string };
}> = ({ contactBook }) => {
const [open, setOpen] = useState(false);
const updateContactBookMutation =
api.contacts.updateContactBook.useMutation();
const utils = api.useUtils();
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
resolver: zodResolver(contactBookSchema),
defaultValues: {
name: contactBook.name || "",
},
});
async function onContactBookUpdate(
values: z.infer<typeof contactBookSchema>
) {
updateContactBookMutation.mutate(
{
contactBookId: contactBook.id,
...values,
},
{
onSuccess: async () => {
utils.contacts.getContactBooks.invalidate();
setOpen(false);
toast.success("Contact book updated successfully");
},
onError: async (error) => {
toast.error(error.message);
},
}
);
}
return (
<Dialog
open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Contact Book</DialogTitle>
</DialogHeader>
<div className="py-2">
<Form {...contactBookForm}>
<form
onSubmit={contactBookForm.handleSubmit(onContactBookUpdate)}
className="space-y-8"
>
<FormField
control={contactBookForm.control}
name="name"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Contact Book Name" {...field} />
</FormControl>
{formState.errors.name ? <FormMessage /> : null}
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
type="submit"
disabled={updateContactBookMutation.isPending}
>
{updateContactBookMutation.isPending
? "Updating..."
: "Update"}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
};
export default EditContactBook;

View File

@@ -0,0 +1,16 @@
"use client";
import AddContactBook from "./add-contact-book";
import ContactBooksList from "./contact-books-list";
export default function ContactsPage() {
return (
<div>
<div className="flex justify-between items-center">
<h1 className="font-bold text-lg">Contact books</h1>
<AddContactBook />
</div>
<ContactBooksList />
</div>
);
}

View File

@@ -66,14 +66,14 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
Domains
</NavButton>
<NavButton href="/contacts" comingSoon>
<NavButton href="/contacts">
<BookUser className="h-4 w-4" />
Contacts
</NavButton>
<NavButton href="/contacts" comingSoon>
<NavButton href="/campaigns">
<Volume2 className="h-4 w-4" />
Marketing
Campaigns
</NavButton>
<NavButton href="/api-keys">
@@ -104,7 +104,7 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
</div>
</div>
<div className="flex flex-1 flex-col">
<header className="flex h-14 items-center gap-4 md:hidden bg-muted/20 px-4 lg:h-[60px] lg:px-6">
<header className=" h-14 items-center gap-4 hidden bg-muted/20 px-4 lg:h-[60px] lg:px-6">
<Sheet>
<SheetTrigger asChild>
<Button

View File

@@ -0,0 +1,50 @@
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
const data = await req.json();
try {
const renderer = new EmailRenderer(data);
const time = Date.now();
const html = await renderer.render({
shouldReplaceVariableValues: true,
linkValues: {
"{{unsend_unsubscribe_url}}": "https://unsend.com/unsubscribe",
},
});
console.log(`Time taken: ${Date.now() - time}ms`);
return new Response(JSON.stringify({ data: html }), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
});
} catch (e) {
console.error(e);
return new Response(
JSON.stringify({ data: "Error in converting to html" }),
{
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
}
);
}
}
export function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
});
}

View File

@@ -25,7 +25,7 @@ export default async function RootLayout({
}) {
return (
<html lang="en">
<body className={`font-sans ${inter.variable}`}>
<body className={`font-sans ${inter.variable} app`}>
<ThemeProvider attribute="class" defaultTheme="dark">
<Toaster />
<TRPCReactProvider>{children}</TRPCReactProvider>

View File

@@ -0,0 +1,51 @@
import { Button } from "@unsend/ui/src/button";
import { Suspense } from "react";
import {
unsubscribeContact,
subscribeContact,
} from "~/server/service/campaign-service";
import ReSubscribe from "./re-subscribe";
export const dynamic = "force-dynamic";
async function UnsubscribePage({
searchParams,
}: {
searchParams: { [key: string]: string | string[] | undefined };
}) {
const id = searchParams.id as string;
const hash = searchParams.hash as string;
if (!id || !hash) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="max-w-md w-full space-y-8 p-10 shadow rounded-xl">
<h2 className="mt-6 text-center text-3xl font-extrabold ">
Unsubscribe
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Invalid unsubscribe link. Please check your URL and try again.
</p>
</div>
</div>
);
}
const contact = await unsubscribeContact(id, hash);
return (
<div className="min-h-screen flex items-center justify-center ">
<ReSubscribe id={id} hash={hash} contact={contact} />
<div className=" fixed bottom-10 p-4">
<p>
Powered by{" "}
<a href="https://unsend.dev" className="font-bold" target="_blank">
Unsend
</a>
</p>
</div>
</div>
);
}
export default UnsubscribePage;

View File

@@ -0,0 +1,60 @@
"use client";
import { Contact } from "@prisma/client";
import { Button } from "@unsend/ui/src/button";
import Spinner from "@unsend/ui/src/spinner";
import { toast } from "@unsend/ui/src/toaster";
import { useState } from "react";
import { api } from "~/trpc/react";
export default function ReSubscribe({
id,
hash,
contact,
}: {
id: string;
hash: string;
contact: Contact;
}) {
const [subscribed, setSubscribed] = useState(false);
const reSubscribe = api.campaign.reSubscribeContact.useMutation({
onSuccess: () => {
toast.success("You have been subscribed again");
setSubscribed(true);
},
onError: (e) => {
toast.error(e.message);
},
});
return (
<div className="max-w-xl w-full space-y-8 p-10 border shadow rounded-xl">
<h2 className=" text-center text-xl font-extrabold ">
{subscribed ? "You have subscribed again" : "You have unsubscribed"}
</h2>
<div>
{subscribed
? "You have been added to our mailing list and will receive all emails at"
: "You have been removed from our mailing list and won't receive any emails at"}{" "}
<span className="font-bold">{contact.email}</span>.
</div>
<div className="flex justify-center">
{!subscribed ? (
<Button
className="mx-auto w-[150px]"
onClick={() => reSubscribe.mutate({ id, hash })}
disabled={reSubscribe.isPending}
>
{reSubscribe.isPending ? (
<Spinner className="w-4 h-4" />
) : (
"Subscribe Again"
)}
</Button>
) : null}
</div>
</div>
);
}

View File

@@ -22,6 +22,7 @@ const FormSchema = z.object({
region: z.string(),
unsendUrl: z.string().url(),
sendRate: z.number(),
transactionalQuota: z.number().min(0).max(100),
});
type SesSettingsProps = {
@@ -56,6 +57,7 @@ export const AddSesSettingsForm: React.FC<SesSettingsProps> = ({
region: "",
unsendUrl: "",
sendRate: 1,
transactionalQuota: 50,
},
});
@@ -167,6 +169,26 @@ export const AddSesSettingsForm: React.FC<SesSettingsProps> = ({
</FormItem>
)}
/>
<FormField
control={form.control}
name="transactionalQuota"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Transactional Quota</FormLabel>
<FormControl>
<Input placeholder="0" className="w-full" {...field} />
</FormControl>
{formState.errors.transactionalQuota ? (
<FormMessage />
) : (
<FormDescription>
The percentage of the quota to be used for transactional
emails (0-100%).
</FormDescription>
)}
</FormItem>
)}
/>
<Button
type="submit"
disabled={addSesSettings.isPending}

View File

@@ -0,0 +1,25 @@
import { useEffect, useRef } from "react";
export function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef<() => void>();
// Remember the latest callback.
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the interval.
useEffect(() => {
function tick() {
if (savedCallback.current) {
savedCallback.current();
}
}
if (delay !== null) {
const id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}
export default useInterval;

View File

@@ -4,6 +4,8 @@ import { apiRouter } from "./routers/api";
import { emailRouter } from "./routers/email";
import { teamRouter } from "./routers/team";
import { adminRouter } from "./routers/admin";
import { contactsRouter } from "./routers/contacts";
import { campaignRouter } from "./routers/campaign";
/**
* This is the primary router for your server.
@@ -16,6 +18,8 @@ export const appRouter = createTRPCRouter({
email: emailRouter,
team: teamRouter,
admin: adminRouter,
contacts: contactsRouter,
campaign: campaignRouter,
});
// export type definition of API

View File

@@ -26,12 +26,32 @@ export const adminRouter = createTRPCRouter({
z.object({
region: z.string(),
unsendUrl: z.string().url(),
sendRate: z.number(),
transactionalQuota: z.number(),
})
)
.mutation(async ({ input }) => {
return SesSettingsService.createSesSetting({
region: input.region,
unsendUrl: input.unsendUrl,
sendingRateLimit: input.sendRate,
transactionalQuota: input.transactionalQuota,
});
}),
updateSesSettings: adminProcedure
.input(
z.object({
settingsId: z.string(),
sendRate: z.number(),
transactionalQuota: z.number(),
})
)
.mutation(async ({ input }) => {
return SesSettingsService.updateSesSetting({
id: input.settingsId,
sendingRateLimit: input.sendRate,
transactionalQuota: input.transactionalQuota,
});
}),

View File

@@ -0,0 +1,183 @@
import { Prisma } from "@prisma/client";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import {
teamProcedure,
createTRPCRouter,
campaignProcedure,
publicProcedure,
} from "~/server/api/trpc";
import {
sendCampaign,
subscribeContact,
} from "~/server/service/campaign-service";
import { validateDomainFromEmail } from "~/server/service/domain-service";
export const campaignRouter = createTRPCRouter({
getCampaigns: teamProcedure
.input(
z.object({
page: z.number().optional(),
})
)
.query(async ({ ctx: { db, team }, input }) => {
const page = input.page || 1;
const limit = 30;
const offset = (page - 1) * limit;
const whereConditions: Prisma.CampaignFindManyArgs["where"] = {
teamId: team.id,
};
const countP = db.campaign.count({ where: whereConditions });
const campaignsP = db.campaign.findMany({
where: whereConditions,
select: {
id: true,
name: true,
from: true,
subject: true,
createdAt: true,
updatedAt: true,
status: true,
},
orderBy: {
createdAt: "desc",
},
skip: offset,
take: limit,
});
const [campaigns, count] = await Promise.all([campaignsP, countP]);
return { campaigns, totalPage: Math.ceil(count / limit) };
}),
createCampaign: teamProcedure
.input(
z.object({
name: z.string(),
from: z.string(),
subject: z.string(),
})
)
.mutation(async ({ ctx: { db, team }, input }) => {
const domain = await validateDomainFromEmail(input.from, team.id);
const campaign = await db.campaign.create({
data: {
...input,
teamId: team.id,
domainId: domain.id,
},
});
return campaign;
}),
updateCampaign: campaignProcedure
.input(
z.object({
name: z.string().optional(),
from: z.string().optional(),
subject: z.string().optional(),
previewText: z.string().optional(),
content: z.string().optional(),
contactBookId: z.string().optional(),
})
)
.mutation(async ({ ctx: { db, team, campaign: campaignOld }, input }) => {
const { campaignId, ...data } = input;
if (data.contactBookId) {
const contactBook = await db.contactBook.findUnique({
where: { id: data.contactBookId },
});
if (!contactBook) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Contact book not found",
});
}
}
let domainId = campaignOld.domainId;
if (data.from) {
const domain = await validateDomainFromEmail(data.from, team.id);
domainId = domain.id;
}
const campaign = await db.campaign.update({
where: { id: campaignId },
data: {
...data,
domainId,
},
});
return campaign;
}),
deleteCampaign: campaignProcedure.mutation(
async ({ ctx: { db, team }, input }) => {
const campaign = await db.campaign.delete({
where: { id: input.campaignId, teamId: team.id },
});
return campaign;
}
),
getCampaign: campaignProcedure.query(async ({ ctx: { db, team }, input }) => {
const campaign = await db.campaign.findUnique({
where: { id: input.campaignId, teamId: team.id },
});
if (!campaign) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Campaign not found",
});
}
if (campaign?.contactBookId) {
const contactBook = await db.contactBook.findUnique({
where: { id: campaign.contactBookId },
});
return { ...campaign, contactBook };
}
return { ...campaign, contactBook: null };
}),
sendCampaign: campaignProcedure.mutation(
async ({ ctx: { db, team }, input }) => {
await sendCampaign(input.campaignId);
}
),
reSubscribeContact: publicProcedure
.input(
z.object({
id: z.string(),
hash: z.string(),
})
)
.mutation(async ({ ctx: { db }, input }) => {
await subscribeContact(input.id, input.hash);
}),
duplicateCampaign: campaignProcedure.mutation(
async ({ ctx: { db, team, campaign }, input }) => {
const newCampaign = await db.campaign.create({
data: {
name: `${campaign.name} (Copy)`,
from: campaign.from,
subject: campaign.subject,
content: campaign.content,
teamId: team.id,
domainId: campaign.domainId,
contactBookId: campaign.contactBookId,
},
});
return newCampaign;
}
),
});

View File

@@ -0,0 +1,168 @@
import { Prisma } from "@prisma/client";
import { z } from "zod";
import {
contactBookProcedure,
createTRPCRouter,
teamProcedure,
} from "~/server/api/trpc";
import * as contactService from "~/server/service/contact-service";
export const contactsRouter = createTRPCRouter({
getContactBooks: teamProcedure.query(async ({ ctx: { db, team } }) => {
return db.contactBook.findMany({
where: {
teamId: team.id,
},
include: {
_count: {
select: { contacts: true },
},
},
});
}),
createContactBook: teamProcedure
.input(
z.object({
name: z.string(),
})
)
.mutation(async ({ ctx: { db, team }, input }) => {
const { name } = input;
const contactBook = await db.contactBook.create({
data: {
name,
teamId: team.id,
properties: {},
},
});
return contactBook;
}),
getContactBookDetails: contactBookProcedure.query(
async ({ ctx: { contactBook, db } }) => {
const [totalContacts, unsubscribedContacts] = await Promise.all([
db.contact.count({
where: { contactBookId: contactBook.id },
}),
db.contact.count({
where: { contactBookId: contactBook.id, subscribed: false },
}),
]);
return {
...contactBook,
totalContacts,
unsubscribedContacts,
};
}
),
updateContactBook: contactBookProcedure
.input(
z.object({
contactBookId: z.string(),
name: z.string().optional(),
properties: z.record(z.string()).optional(),
})
)
.mutation(async ({ ctx: { db }, input }) => {
const { contactBookId, ...data } = input;
return db.contactBook.update({
where: { id: contactBookId },
data,
});
}),
deleteContactBook: contactBookProcedure
.input(z.object({ contactBookId: z.string() }))
.mutation(async ({ ctx: { db }, input }) => {
return db.contactBook.delete({ where: { id: input.contactBookId } });
}),
contacts: contactBookProcedure
.input(
z.object({
page: z.number().optional(),
subscribed: z.boolean().optional(),
})
)
.query(async ({ ctx: { db }, input }) => {
const page = input.page || 1;
const limit = 30;
const offset = (page - 1) * limit;
const whereConditions: Prisma.ContactFindManyArgs["where"] = {
contactBookId: input.contactBookId,
...(input.subscribed !== undefined
? { subscribed: input.subscribed }
: {}),
};
const countP = db.contact.count({ where: whereConditions });
const contactsP = db.contact.findMany({
where: whereConditions,
select: {
id: true,
email: true,
firstName: true,
lastName: true,
subscribed: true,
createdAt: true,
contactBookId: true,
},
orderBy: {
createdAt: "desc",
},
skip: offset,
take: limit,
});
const [contacts, count] = await Promise.all([contactsP, countP]);
return { contacts, totalPage: Math.ceil(count / limit) };
}),
addContacts: contactBookProcedure
.input(
z.object({
contacts: z.array(
z.object({
email: z.string(),
firstName: z.string().optional(),
lastName: z.string().optional(),
properties: z.record(z.string()).optional(),
subscribed: z.boolean().optional(),
})
),
})
)
.mutation(async ({ ctx: { contactBook }, input }) => {
return contactService.bulkAddContacts(contactBook.id, input.contacts);
}),
updateContact: contactBookProcedure
.input(
z.object({
contactId: z.string(),
email: z.string().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
properties: z.record(z.string()).optional(),
subscribed: z.boolean().optional(),
})
)
.mutation(async ({ input }) => {
const { contactId, ...contact } = input;
return contactService.updateContact(contactId, contact);
}),
deleteContact: contactBookProcedure
.input(z.object({ contactId: z.string() }))
.mutation(async ({ input }) => {
return contactService.deleteContact(input.contactId);
}),
});

View File

@@ -175,7 +175,7 @@ export const emailRouter = createTRPCRouter({
select: {
emailEvents: {
orderBy: {
createdAt: "asc",
status: "asc",
},
},
id: true,

View File

@@ -9,7 +9,7 @@
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { z, ZodError } from "zod";
import { env } from "~/env";
import { getServerAuthSession } from "~/server/auth";
@@ -125,6 +125,60 @@ export const teamProcedure = protectedProcedure.use(async ({ ctx, next }) => {
});
});
export const contactBookProcedure = teamProcedure
.input(
z.object({
contactBookId: z.string(),
})
)
.use(async ({ ctx, next, input }) => {
const contactBook = await db.contactBook.findUnique({
where: { id: input.contactBookId },
});
if (!contactBook) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Contact book not found",
});
}
if (contactBook.teamId !== ctx.team.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this contact book",
});
}
return next({ ctx: { ...ctx, contactBook } });
});
export const campaignProcedure = teamProcedure
.input(
z.object({
campaignId: z.string(),
})
)
.use(async ({ ctx, next, input }) => {
const campaign = await db.campaign.findUnique({
where: { id: input.campaignId },
});
if (!campaign) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Campaign not found",
});
}
if (campaign.teamId !== ctx.team.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this campaign",
});
}
return next({ ctx: { ...ctx, campaign } });
});
/**
* To manage application settings, for hosted version, authenticated users will be considered as admin
*/

View File

@@ -112,6 +112,7 @@ export async function sendEmailThroughSes({
replyTo,
region,
configurationSetName,
unsubUrl,
}: Partial<EmailContent> & {
region: string;
configurationSetName: string;
@@ -149,6 +150,14 @@ export async function sendEmailThroughSes({
Charset: "UTF-8",
},
},
...(unsubUrl
? {
Headers: [
{ Name: "List-Unsubscribe", Value: `<${unsubUrl}>` },
{ Name: "List-Unsubscribe-Post", Value: "One-Click" },
],
}
: {}),
},
},
ConfigurationSetName: configurationSetName,

View File

@@ -0,0 +1,27 @@
import { Context } from "hono";
import { db } from "../db";
import { UnsendApiError } from "./api-error";
export const getContactBook = async (c: Context, teamId: number) => {
const contactBookId = c.req.param("contactBookId");
if (!contactBookId) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: "contactBookId is mandatory",
});
}
const contactBook = await db.contactBook.findUnique({
where: { id: contactBookId, teamId },
});
if (!contactBook) {
throw new UnsendApiError({
code: "NOT_FOUND",
message: "Contact book not found for this team",
});
}
return contactBook;
};

View File

@@ -0,0 +1,65 @@
import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
import { getTeamFromToken } from "~/server/public-api/auth";
import { addOrUpdateContact } from "~/server/service/contact-service";
import { getContactBook } from "../../api-utils";
const route = createRoute({
method: "post",
path: "/v1/contactBooks/{contactBookId}/contacts",
request: {
params: z.object({
contactBookId: z
.string()
.min(3)
.openapi({
param: {
name: "contactBookId",
in: "path",
},
example: "cuiwqdj74rygf74",
}),
}),
body: {
required: true,
content: {
"application/json": {
schema: z.object({
email: z.string(),
firstName: z.string().optional(),
lastName: z.string().optional(),
properties: z.record(z.string()).optional(),
subscribed: z.boolean().optional(),
}),
},
},
},
},
responses: {
200: {
content: {
"application/json": {
schema: z.object({ contactId: z.string().optional() }),
},
},
description: "Retrieve the user",
},
},
});
function addContact(app: PublicAPIApp) {
app.openapi(route, async (c) => {
const team = await getTeamFromToken(c);
const contactBook = await getContactBook(c, team.id);
const contact = await addOrUpdateContact(
contactBook.id,
c.req.valid("json")
);
return c.json({ contactId: contact.id });
});
}
export default addContact;

View File

@@ -0,0 +1,82 @@
import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
import { getTeamFromToken } from "~/server/public-api/auth";
import { db } from "~/server/db";
import { UnsendApiError } from "../../api-error";
import { getContactBook } from "../../api-utils";
const route = createRoute({
method: "get",
path: "/v1/contactBooks/{contactBookId}/contacts/{contactId}",
request: {
params: z.object({
contactBookId: z.string().openapi({
param: {
name: "contactBookId",
in: "path",
},
example: "cuiwqdj74rygf74",
}),
contactId: z.string().openapi({
param: {
name: "contactId",
in: "path",
},
example: "cuiwqdj74rygf74",
}),
}),
},
responses: {
200: {
content: {
"application/json": {
schema: z.object({
id: z.string(),
firstName: z.string().optional().nullable(),
lastName: z.string().optional().nullable(),
email: z.string(),
subscribed: z.boolean().default(true),
properties: z.record(z.string()),
contactBookId: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
}),
},
},
description: "Retrieve the contact",
},
},
});
function getContact(app: PublicAPIApp) {
app.openapi(route, async (c) => {
const team = await getTeamFromToken(c);
await getContactBook(c, team.id);
const contactId = c.req.param("contactId");
const contact = await db.contact.findUnique({
where: {
id: contactId,
},
});
if (!contact) {
throw new UnsendApiError({
code: "NOT_FOUND",
message: "Contact not found",
});
}
// Ensure properties is a Record<string, string>
const sanitizedContact = {
...contact,
properties: contact.properties as Record<string, string>,
};
return c.json(sanitizedContact);
});
}
export default getContact;

View File

@@ -0,0 +1,66 @@
import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
import { getTeamFromToken } from "~/server/public-api/auth";
import { updateContact } from "~/server/service/contact-service";
import { getContactBook } from "../../api-utils";
const route = createRoute({
method: "patch",
path: "/v1/contactBooks/{contactBookId}/contacts/{contactId}",
request: {
params: z.object({
contactBookId: z.string().openapi({
param: {
name: "contactBookId",
in: "path",
},
example: "cuiwqdj74rygf74",
}),
contactId: z.string().openapi({
param: {
name: "contactId",
in: "path",
},
example: "cuiwqdj74rygf74",
}),
}),
body: {
required: true,
content: {
"application/json": {
schema: z.object({
firstName: z.string().optional(),
lastName: z.string().optional(),
properties: z.record(z.string()).optional(),
subscribed: z.boolean().optional(),
}),
},
},
},
},
responses: {
200: {
content: {
"application/json": {
schema: z.object({ contactId: z.string().optional() }),
},
},
description: "Retrieve the user",
},
},
});
function updateContactInfo(app: PublicAPIApp) {
app.openapi(route, async (c) => {
const team = await getTeamFromToken(c);
await getContactBook(c, team.id);
const contactId = c.req.param("contactId");
const contact = await updateContact(contactId, c.req.valid("json"));
return c.json({ contactId: contact.id });
});
}
export default updateContactInfo;

View File

@@ -50,7 +50,7 @@ const route = createRoute({
}),
},
},
description: "Retrieve the user",
description: "Retrieve the email",
},
},
});

View File

@@ -2,6 +2,9 @@ import { getApp } from "./hono";
import getDomains from "./api/domains/get-domains";
import sendEmail from "./api/emails/send-email";
import getEmail from "./api/emails/get-email";
import addContact from "./api/contacts/add-contact";
import updateContactInfo from "./api/contacts/update-contact";
import getContact from "./api/contacts/get-contact";
export const app = getApp();
@@ -12,4 +15,9 @@ getDomains(app);
getEmail(app);
sendEmail(app);
/**Contact related APIs */
addContact(app);
updateContactInfo(app);
getContact(app);
export default app;

View File

@@ -0,0 +1,309 @@
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
import { db } from "../db";
import { createHash } from "crypto";
import { env } from "~/env";
import { Campaign, Contact, EmailStatus } from "@prisma/client";
import { validateDomainFromEmail } from "./domain-service";
import { EmailQueueService } from "./email-queue-service";
export async function sendCampaign(id: string) {
let campaign = await db.campaign.findUnique({
where: { id },
});
if (!campaign) {
throw new Error("Campaign not found");
}
if (!campaign.content) {
throw new Error("No content added for campaign");
}
let jsonContent: Record<string, any>;
try {
jsonContent = JSON.parse(campaign.content);
const renderer = new EmailRenderer(jsonContent);
const html = await renderer.render();
campaign = await db.campaign.update({
where: { id },
data: { html },
});
} catch (error) {
console.error(error);
throw new Error("Failed to parse campaign content");
}
if (!campaign.contactBookId) {
throw new Error("No contact book found for campaign");
}
const contactBook = await db.contactBook.findUnique({
where: { id: campaign.contactBookId },
include: {
contacts: {
where: {
subscribed: true,
},
},
},
});
if (!contactBook) {
throw new Error("Contact book not found");
}
if (!campaign.html) {
throw new Error("No HTML content for campaign");
}
await sendCampaignEmail(campaign, {
campaignId: campaign.id,
from: campaign.from,
subject: campaign.subject,
html: campaign.html,
replyTo: campaign.replyTo,
cc: campaign.cc,
bcc: campaign.bcc,
teamId: campaign.teamId,
contacts: contactBook.contacts,
});
await db.campaign.update({
where: { id },
data: { status: "SENT", total: contactBook.contacts.length },
});
}
export function createUnsubUrl(contactId: string, campaignId: string) {
const unsubId = `${contactId}-${campaignId}`;
const unsubHash = createHash("sha256")
.update(`${unsubId}-${env.NEXTAUTH_SECRET}`)
.digest("hex");
return `${env.NEXTAUTH_URL}/unsubscribe?id=${unsubId}&hash=${unsubHash}`;
}
export async function unsubscribeContact(id: string, hash: string) {
const [contactId, campaignId] = id.split("-");
if (!contactId || !campaignId) {
throw new Error("Invalid unsubscribe link");
}
// Verify the hash
const expectedHash = createHash("sha256")
.update(`${id}-${env.NEXTAUTH_SECRET}`)
.digest("hex");
if (hash !== expectedHash) {
throw new Error("Invalid unsubscribe link");
}
// Update the contact's subscription status
try {
const contact = await db.contact.findUnique({
where: { id: contactId },
});
if (!contact) {
throw new Error("Contact not found");
}
if (contact.subscribed) {
await db.contact.update({
where: { id: contactId },
data: { subscribed: false },
});
await db.campaign.update({
where: { id: campaignId },
data: {
unsubscribed: {
increment: 1,
},
},
});
}
return contact;
} catch (error) {
console.error("Error unsubscribing contact:", error);
throw new Error("Failed to unsubscribe contact");
}
}
export async function subscribeContact(id: string, hash: string) {
const [contactId, campaignId] = id.split("-");
if (!contactId || !campaignId) {
throw new Error("Invalid subscribe link");
}
// Verify the hash
const expectedHash = createHash("sha256")
.update(`${id}-${env.NEXTAUTH_SECRET}`)
.digest("hex");
if (hash !== expectedHash) {
throw new Error("Invalid subscribe link");
}
// Update the contact's subscription status
try {
const contact = await db.contact.findUnique({
where: { id: contactId },
});
if (!contact) {
throw new Error("Contact not found");
}
if (!contact.subscribed) {
await db.contact.update({
where: { id: contactId },
data: { subscribed: true },
});
await db.campaign.update({
where: { id: campaignId },
data: {
unsubscribed: {
decrement: 1,
},
},
});
}
return true;
} catch (error) {
console.error("Error subscribing contact:", error);
throw new Error("Failed to subscribe contact");
}
}
type CampainEmail = {
campaignId: string;
from: string;
subject: string;
html: string;
replyTo?: string[];
cc?: string[];
bcc?: string[];
teamId: number;
contacts: Array<Contact>;
};
export async function sendCampaignEmail(
campaign: Campaign,
emailData: CampainEmail
) {
const { campaignId, from, subject, replyTo, cc, bcc, teamId, contacts } =
emailData;
const jsonContent = JSON.parse(campaign.content || "{}");
const renderer = new EmailRenderer(jsonContent);
const domain = await validateDomainFromEmail(from, teamId);
const contactWithHtml = await Promise.all(
contacts.map(async (contact) => {
const unsubscribeUrl = createUnsubUrl(contact.id, campaignId);
return {
...contact,
html: await renderer.render({
shouldReplaceVariableValues: true,
variableValues: {
email: contact.email,
firstName: contact.firstName,
lastName: contact.lastName,
},
linkValues: {
"{{unsend_unsubscribe_url}}": unsubscribeUrl,
},
}),
};
})
);
// Create emails in bulk
await db.email.createMany({
data: contactWithHtml.map((contact) => ({
to: [contact.email],
replyTo: replyTo
? Array.isArray(replyTo)
? replyTo
: [replyTo]
: undefined,
cc: cc ? (Array.isArray(cc) ? cc : [cc]) : undefined,
bcc: bcc ? (Array.isArray(bcc) ? bcc : [bcc]) : undefined,
from,
subject,
html: contact.html,
teamId,
campaignId,
contactId: contact.id,
domainId: domain.id,
})),
});
// Fetch created emails
const emails = await db.email.findMany({
where: {
teamId,
campaignId,
},
});
// Queue emails
await Promise.all(
emails.map((email) =>
EmailQueueService.queueEmail(email.id, domain.region, false)
)
);
}
export async function updateCampaignAnalytics(
campaignId: string,
emailStatus: EmailStatus
) {
const campaign = await db.campaign.findUnique({
where: { id: campaignId },
});
if (!campaign) {
throw new Error("Campaign not found");
}
const updateData: Record<string, any> = {};
switch (emailStatus) {
case EmailStatus.SENT:
updateData.sent = { increment: 1 };
break;
case EmailStatus.DELIVERED:
updateData.delivered = { increment: 1 };
break;
case EmailStatus.OPENED:
updateData.opened = { increment: 1 };
break;
case EmailStatus.CLICKED:
updateData.clicked = { increment: 1 };
break;
case EmailStatus.BOUNCED:
updateData.bounced = { increment: 1 };
break;
case EmailStatus.COMPLAINED:
updateData.complained = { increment: 1 };
break;
default:
break;
}
await db.campaign.update({
where: { id: campaignId },
data: updateData,
});
}

View File

@@ -0,0 +1,92 @@
import { db } from "../db";
export type ContactInput = {
email: string;
firstName?: string;
lastName?: string;
properties?: Record<string, string>;
subscribed?: boolean;
};
export async function addOrUpdateContact(
contactBookId: string,
contact: ContactInput
) {
const createdContact = await db.contact.upsert({
where: {
contactBookId_email: {
contactBookId,
email: contact.email,
},
},
create: {
contactBookId,
email: contact.email,
firstName: contact.firstName,
lastName: contact.lastName,
properties: contact.properties ?? {},
subscribed: contact.subscribed,
},
update: {
firstName: contact.firstName,
lastName: contact.lastName,
properties: contact.properties ?? {},
subscribed: contact.subscribed,
},
});
return createdContact;
}
export async function updateContact(
contactId: string,
contact: Partial<ContactInput>
) {
return db.contact.update({
where: {
id: contactId,
},
data: contact,
});
}
export async function deleteContact(contactId: string) {
return db.contact.delete({
where: {
id: contactId,
},
});
}
export async function bulkAddContacts(
contactBookId: string,
contacts: Array<ContactInput>
) {
const createdContacts = await Promise.all(
contacts.map((contact) => addOrUpdateContact(contactBookId, contact))
);
return createdContacts;
}
export async function unsubscribeContact(contactId: string) {
await db.contact.update({
where: {
id: contactId,
},
data: {
subscribed: false,
},
});
}
export async function subscribeContact(contactId: string) {
await db.contact.update({
where: {
id: contactId,
},
data: {
subscribed: true,
},
});
}

View File

@@ -4,9 +4,45 @@ import * as tldts from "tldts";
import * as ses from "~/server/aws/ses";
import { db } from "~/server/db";
import { SesSettingsService } from "./ses-settings-service";
import { UnsendApiError } from "../public-api/api-error";
const dnsResolveTxt = util.promisify(dns.resolveTxt);
export async function validateDomainFromEmail(email: string, teamId: number) {
let fromDomain = email.split("@")[1];
if (fromDomain?.endsWith(">")) {
fromDomain = fromDomain.slice(0, -1);
}
if (!fromDomain) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: "From email is invalid",
});
}
const domain = await db.domain.findUnique({
where: { name: fromDomain, teamId },
});
if (!domain) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message:
"Domain of from email is wrong. Use the domain verified by unsend",
});
}
if (domain.status !== "SUCCESS") {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: "Domain is not verified",
});
}
return domain;
}
export async function createDomain(
teamId: number,
name: string,

View File

@@ -5,54 +5,120 @@ import { getConfigurationSetName } from "~/utils/ses-utils";
import { db } from "../db";
import { sendEmailThroughSes, sendEmailWithAttachments } from "../aws/ses";
import { getRedis } from "../redis";
import { createUnsubUrl } from "./campaign-service";
function createQueueAndWorker(region: string, quota: number, suffix: string) {
const connection = getRedis();
const queueName = `${region}-${suffix}`;
const queue = new Queue(queueName, { connection });
const worker = new Worker(queueName, executeEmail, {
concurrency: quota,
connection,
});
return { queue, worker };
}
export class EmailQueueService {
private static initialized = false;
private static regionQueue = new Map<string, Queue>();
private static regionWorker = new Map<string, Worker>();
public static transactionalQueue = new Map<string, Queue>();
private static transactionalWorker = new Map<string, Worker>();
public static marketingQueue = new Map<string, Queue>();
private static marketingWorker = new Map<string, Worker>();
public static initializeQueue(region: string, quota: number) {
const connection = getRedis();
public static initializeQueue(
region: string,
quota: number,
transactionalQuotaPercentage: number
) {
console.log(`[EmailQueueService]: Initializing queue for region ${region}`);
const queueName = `${region}-transaction`;
const transactionalQuota = Math.floor(
(quota * transactionalQuotaPercentage) / 100
);
const marketingQuota = quota - transactionalQuota;
const queue = new Queue(queueName, { connection });
console.log(
"is transactional queue",
this.transactionalQueue.has(region),
"is marketing queue",
this.marketingQueue.has(region)
);
const worker = new Worker(queueName, executeEmail, {
limiter: {
max: quota,
duration: 1000,
},
concurrency: quota,
connection,
});
if (this.transactionalQueue.has(region)) {
console.log(
`[EmailQueueService]: Updating transactional quota for region ${region} to ${transactionalQuota}`
);
const transactionalWorker = this.transactionalWorker.get(region);
if (transactionalWorker) {
transactionalWorker.concurrency = transactionalQuota;
}
} else {
console.log(
`[EmailQueueService]: Creating transactional queue for region ${region} with quota ${transactionalQuota}`
);
const { queue: transactionalQueue, worker: transactionalWorker } =
createQueueAndWorker(region, transactionalQuota ?? 1, "transaction");
this.transactionalQueue.set(region, transactionalQueue);
this.transactionalWorker.set(region, transactionalWorker);
}
this.regionQueue.set(region, queue);
this.regionWorker.set(region, worker);
if (this.marketingQueue.has(region)) {
console.log(
`[EmailQueueService]: Updating marketing quota for region ${region} to ${marketingQuota}`
);
const marketingWorker = this.marketingWorker.get(region);
if (marketingWorker) {
marketingWorker.concurrency = marketingQuota;
}
} else {
console.log(
`[EmailQueueService]: Creating marketing queue for region ${region} with quota ${marketingQuota}`
);
const { queue: marketingQueue, worker: marketingWorker } =
createQueueAndWorker(region, marketingQuota ?? 1, "marketing");
this.marketingQueue.set(region, marketingQueue);
this.marketingWorker.set(region, marketingWorker);
}
}
public static async queueEmail(emailId: string, region: string) {
public static async queueEmail(
emailId: string,
region: string,
transactional: boolean,
unsubUrl?: string
) {
if (!this.initialized) {
await this.init();
}
const queue = this.regionQueue.get(region);
const queue = transactional
? this.transactionalQueue.get(region)
: this.marketingQueue.get(region);
if (!queue) {
throw new Error(`Queue for region ${region} not found`);
}
queue.add("send-email", { emailId, timestamp: Date.now() });
queue.add("send-email", { emailId, timestamp: Date.now(), unsubUrl });
}
public static async init() {
const sesSettings = await db.sesSetting.findMany();
for (const sesSetting of sesSettings) {
this.initializeQueue(sesSetting.region, sesSetting.sesEmailRateLimit);
this.initializeQueue(
sesSetting.region,
sesSetting.sesEmailRateLimit,
sesSetting.transactionalQuota
);
}
this.initialized = true;
}
}
async function executeEmail(job: Job<{ emailId: string; timestamp: number }>) {
async function executeEmail(
job: Job<{ emailId: string; timestamp: number; unsubUrl?: string }>
) {
console.log(
`[EmailQueueService]: Executing email job ${job.data.emailId}, time elapsed: ${Date.now() - job.data.timestamp}ms`
);
@@ -88,13 +154,15 @@ async function executeEmail(job: Job<{ emailId: string; timestamp: number }>) {
}
console.log(`[EmailQueueService]: Sending email ${email.id}`);
const unsubUrl = job.data.unsubUrl;
try {
const messageId = attachments.length
? await sendEmailWithAttachments({
to: email.to,
from: email.from,
subject: email.subject,
text: email.text ?? undefined,
text: email.text ?? "",
html: email.html ?? undefined,
region: domain?.region ?? env.AWS_DEFAULT_REGION,
configurationSetName,
@@ -105,11 +173,12 @@ async function executeEmail(job: Job<{ emailId: string; timestamp: number }>) {
from: email.from,
subject: email.subject,
replyTo: email.replyTo ?? undefined,
text: email.text ?? undefined,
text: email.text ?? "",
html: email.html ?? undefined,
region: domain?.region ?? env.AWS_DEFAULT_REGION,
configurationSetName,
attachments,
unsubUrl,
});
// Delete attachments after sending the email

View File

@@ -2,7 +2,14 @@ import { EmailContent } from "~/types";
import { db } from "../db";
import { UnsendApiError } from "~/server/public-api/api-error";
import { EmailQueueService } from "./email-queue-service";
import { validateDomainFromEmail } from "./domain-service";
import { Campaign, Contact } from "@prisma/client";
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
import { createUnsubUrl } from "./campaign-service";
/**
Send transactional email
*/
export async function sendEmail(
emailContent: EmailContent & { teamId: number }
) {
@@ -19,29 +26,7 @@ export async function sendEmail(
bcc,
} = emailContent;
let fromDomain = from.split("@")[1];
if (fromDomain?.endsWith(">")) {
fromDomain = fromDomain.slice(0, -1);
}
const domain = await db.domain.findFirst({
where: { teamId, name: fromDomain },
});
if (!domain) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message:
"Domain of from email is wrong. Use the email verified by unsend",
});
}
if (domain.status !== "SUCCESS") {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: "Domain is not verified",
});
}
const domain = await validateDomainFromEmail(from, teamId);
const email = await db.email.create({
data: {
@@ -64,7 +49,7 @@ export async function sendEmail(
});
try {
await EmailQueueService.queueEmail(email.id, domain.region);
await EmailQueueService.queueEmail(email.id, domain.region, true);
} catch (error: any) {
await db.emailEvent.create({
data: {

View File

@@ -1,8 +1,8 @@
import { EmailStatus } from "@prisma/client";
import { SesEvent, SesEventDataKey } from "~/types/aws-types";
import { EmailStatus, Prisma } from "@prisma/client";
import { SesClick, SesEvent, SesEventDataKey } from "~/types/aws-types";
import { db } from "../db";
const STATUS_LIST = Object.values(EmailStatus);
import { updateCampaignAnalytics } from "./campaign-service";
import { env } from "~/env";
export async function parseSesHook(data: SesEvent) {
const mailStatus = getEmailStatus(data);
@@ -34,14 +34,34 @@ export async function parseSesHook(data: SesEvent) {
return true;
}
await db.email.update({
where: {
id: email.id,
},
data: {
latestStatus: getLatestStatus(email.latestStatus, mailStatus),
},
});
// Update the latest status and to avoid race conditions
await db.$executeRaw`
UPDATE "Email"
SET "latestStatus" = CASE
WHEN ${mailStatus}::text::\"EmailStatus\" > "latestStatus" OR "latestStatus" IS NULL
THEN ${mailStatus}::text::\"EmailStatus\"
ELSE "latestStatus"
END
WHERE id = ${email.id}
`;
if (email.campaignId) {
if (
mailStatus !== "CLICKED" ||
!(mailData as SesClick).link.startsWith(`${env.NEXTAUTH_URL}/unsubscribe`)
) {
const mailEvent = await db.emailEvent.findFirst({
where: {
emailId: email.id,
status: mailStatus,
},
});
if (!mailEvent) {
await updateCampaignAnalytics(email.campaignId, mailStatus);
}
}
}
await db.emailEvent.create({
data: {
@@ -89,12 +109,3 @@ function getEmailData(data: SesEvent) {
return data[eventType.toLowerCase() as SesEventDataKey];
}
}
function getLatestStatus(
currentEmailStatus: EmailStatus,
incomingStatus: EmailStatus
) {
const index = STATUS_LIST.indexOf(currentEmailStatus);
const incomingIndex = STATUS_LIST.indexOf(incomingStatus);
return STATUS_LIST[Math.max(index, incomingIndex)] ?? incomingStatus;
}

View File

@@ -52,9 +52,13 @@ export class SesSettingsService {
public static async createSesSetting({
region,
unsendUrl,
sendingRateLimit,
transactionalQuota,
}: {
region: string;
unsendUrl: string;
sendingRateLimit: number;
transactionalQuota: number;
}) {
await this.checkInitialized();
if (this.cache[region]) {
@@ -80,12 +84,62 @@ export class SesSettingsService {
region,
callbackUrl: `${parsedUrl}/api/ses_callback`,
topic: `${idPrefix}-${region}-unsend`,
sesEmailRateLimit: sendingRateLimit,
transactionalQuota,
idPrefix,
},
});
await createSettingInAws(setting);
EmailQueueService.initializeQueue(region, setting.sesEmailRateLimit);
EmailQueueService.initializeQueue(
region,
setting.sesEmailRateLimit,
setting.transactionalQuota
);
console.log(
EmailQueueService.transactionalQueue,
EmailQueueService.marketingQueue
);
await this.invalidateCache();
}
public static async updateSesSetting({
id,
sendingRateLimit,
transactionalQuota,
}: {
id: string;
sendingRateLimit: number;
transactionalQuota: number;
}) {
await this.checkInitialized();
const setting = await db.sesSetting.update({
where: {
id,
},
data: {
transactionalQuota,
sesEmailRateLimit: sendingRateLimit,
},
});
console.log(
EmailQueueService.transactionalQueue,
EmailQueueService.marketingQueue
);
EmailQueueService.initializeQueue(
setting.region,
setting.sesEmailRateLimit,
setting.transactionalQuota
);
console.log(
EmailQueueService.transactionalQueue,
EmailQueueService.marketingQueue
);
await this.invalidateCache();
}

View File

@@ -8,6 +8,7 @@ export type EmailContent = {
cc?: string | string[];
bcc?: string | string[];
attachments?: Array<EmailAttachment>;
unsubUrl?: string;
};
export type EmailAttachment = {