feat: add templates for transactional emails (#103)

* add template migration & router

* template CRUD

* templated transactional emails API

* zod schema fix & rearranging template columns
This commit is contained in:
Ganapathy S
2025-03-07 00:50:25 +05:30
committed by KM Koushik
parent 1c2417df2f
commit 38314a35dc
16 changed files with 981 additions and 8 deletions

View File

@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "Template" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"teamId" INTEGER NOT NULL,
"subject" TEXT NOT NULL,
"html" TEXT,
"content" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Template_createdAt_idx" ON "Template"("createdAt" DESC);
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -101,6 +101,7 @@ model Team {
emails Email[] emails Email[]
contactBooks ContactBook[] contactBooks ContactBook[]
campaigns Campaign[] campaigns Campaign[]
templates Template[]
dailyEmailUsages DailyEmailUsage[] dailyEmailUsages DailyEmailUsage[]
} }
@@ -286,6 +287,20 @@ model Campaign {
@@index([createdAt(sort: Desc)]) @@index([createdAt(sort: Desc)])
} }
model Template {
id String @id @default(cuid())
name String
teamId Int
subject String
html String?
content String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@index([createdAt(sort: Desc)])
}
enum EmailUsageType { enum EmailUsageType {
TRANSACTIONAL TRANSACTIONAL
MARKETING MARKETING

View File

@@ -11,6 +11,7 @@ import {
Globe, Globe,
Home, Home,
LayoutDashboard, LayoutDashboard,
LayoutTemplate,
LineChart, LineChart,
Mail, Mail,
Menu, Menu,
@@ -67,6 +68,11 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
Contacts Contacts
</NavButton> </NavButton>
<NavButton href="/templates">
<LayoutTemplate className="h-4 w-4" />
Templates
</NavButton>
<NavButton href="/campaigns"> <NavButton href="/campaigns">
<Volume2 className="h-4 w-4" /> <Volume2 className="h-4 w-4" />
Campaigns Campaigns

View File

@@ -0,0 +1,218 @@
"use client";
import { api } from "~/trpc/react";
import { Spinner } from "@unsend/ui/src/spinner";
import { Input } from "@unsend/ui/src/input";
import { Editor } from "@unsend/email-editor";
import { useState } from "react";
import { Template } from "@prisma/client";
import { toast } from "@unsend/ui/src/toaster";
import { useDebouncedCallback } from "use-debounce";
import { formatDistanceToNow } from "date-fns";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
const IMAGE_SIZE_LIMIT = 10 * 1024 * 1024;
export default function EditTemplatePage({
params,
}: {
params: { templateId: string };
}) {
const {
data: template,
isLoading,
error,
} = api.template.getTemplate.useQuery(
{ templateId: params.templateId },
{
enabled: !!params.templateId,
}
);
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 template</p>
</div>
);
}
if (!template) {
return <div>Template not found</div>;
}
return <TemplateEditor template={template} />;
}
function TemplateEditor({
template,
}: {
template: Template & { imageUploadSupported: boolean };
}) {
const utils = api.useUtils();
const [json, setJson] = useState<Record<string, any> | undefined>(
template.content ? JSON.parse(template.content) : undefined
);
const [isSaving, setIsSaving] = useState(false);
const [name, setName] = useState(template.name);
const [subject, setSubject] = useState(template.subject);
const updateTemplateMutation = api.template.updateTemplate.useMutation({
onSuccess: () => {
utils.template.getTemplate.invalidate();
setIsSaving(false);
},
});
const getUploadUrl = api.template.generateImagePresignedUrl.useMutation();
function updateEditorContent() {
updateTemplateMutation.mutate({
templateId: template.id,
content: JSON.stringify(json),
});
}
const deboucedUpdateTemplate = useDebouncedCallback(
updateEditorContent,
1000
);
const handleFileChange = async (file: File) => {
if (file.size > IMAGE_SIZE_LIMIT) {
throw new Error(
`File should be less than ${IMAGE_SIZE_LIMIT / 1024 / 1024}MB`
);
}
console.log("file type: ", file.type);
const { uploadUrl, imageUrl } = await getUploadUrl.mutateAsync({
name: file.name,
type: file.type,
templateId: template.id,
});
const response = await fetch(uploadUrl, {
method: "PUT",
body: file,
});
if (!response.ok) {
throw new Error("Failed to upload file");
}
return imageUrl;
};
return (
<div className="p-4 container mx-auto">
<div className="mx-auto">
<div className="mb-4 flex justify-between items-center w-[700px] mx-auto">
<div className="flex items-center gap-3">
<Link href="/templates">
<ArrowLeft className="h-4 w-4" />
</Link>
<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 === template.name || !name) {
return;
}
updateTemplateMutation.mutate(
{
templateId: template.id,
name,
},
{
onError: (e) => {
toast.error(`${e.message}. Reverting changes.`);
setName(template.name);
},
}
);
}}
/>
</div>
<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(template.updatedAt) === "less than a minute"
? "just now"
: `${formatDistanceToNow(template.updatedAt)} ago`}
</div>
</div>
</div>
<div className="flex flex-col mt-4 mb-4 p-4 w-[700px] mx-auto z-50">
<div className="flex items-center gap-4">
<label className="block text-sm w-[80px] text-muted-foreground">
Subject
</label>
<input
type="text"
value={subject}
onChange={(e) => {
setSubject(e.target.value);
}}
onBlur={() => {
if (subject === template.subject || !subject) {
return;
}
updateTemplateMutation.mutate(
{
templateId: template.id,
subject,
},
{
onError: (e) => {
toast.error(`${e.message}. Reverting changes.`);
setSubject(template.subject);
},
}
);
}}
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent focus:border-border bg-transparent"
/>
</div>
</div>
<div className=" rounded-lg bg-gray-50 w-[700px] mx-auto p-10">
<div className="w-[600px] mx-auto">
<Editor
initialContent={json}
onUpdate={(content) => {
setJson(content.getJSON());
setIsSaving(true);
deboucedUpdateTemplate();
}}
variables={["email", "firstName", "lastName"]}
uploadImage={
template.imageUploadSupported ? handleFileChange : undefined
}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
"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 templateSchema = z.object({
name: z.string({ required_error: "Name is required" }).min(1, {
message: "Name is required",
}),
subject: z.string({ required_error: "Subject is required" }).min(1, {
message: "Subject is required",
}),
});
export default function CreateTemplate() {
const router = useRouter();
const [open, setOpen] = useState(false);
const createTemplateMutation = api.template.createTemplate.useMutation();
const templateForm = useForm<z.infer<typeof templateSchema>>({
resolver: zodResolver(templateSchema),
defaultValues: {
name: "",
subject: "",
},
});
const utils = api.useUtils();
async function onTemplateCreate(values: z.infer<typeof templateSchema>) {
createTemplateMutation.mutate(
{
name: values.name,
subject: values.subject,
},
{
onSuccess: async (data) => {
utils.template.getTemplates.invalidate();
router.push(`/templates/${data.id}/edit`);
toast.success("Template 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 Template
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create new template</DialogTitle>
</DialogHeader>
<div className="py-2">
<Form {...templateForm}>
<form
onSubmit={templateForm.handleSubmit(onTemplateCreate)}
className="space-y-8"
>
<FormField
control={templateForm.control}
name="name"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Template Name" {...field} />
</FormControl>
{formState.errors.name ? <FormMessage /> : null}
</FormItem>
)}
/>
<FormField
control={templateForm.control}
name="subject"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Subject</FormLabel>
<FormControl>
<Input placeholder="Template 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={createTemplateMutation.isPending}
>
{createTemplateMutation.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 { Template } from "@prisma/client";
const templateSchema = z.object({
name: z.string(),
});
export const DeleteTemplate: React.FC<{
template: Partial<Template> & { id: string };
}> = ({ template }) => {
const [open, setOpen] = useState(false);
const deleteTemplateMutation = api.template.deleteTemplate.useMutation();
const utils = api.useUtils();
const templateForm = useForm<z.infer<typeof templateSchema>>({
resolver: zodResolver(templateSchema),
});
async function onTemplateDelete(values: z.infer<typeof templateSchema>) {
if (values.name !== template.name) {
templateForm.setError("name", {
message: "Name does not match",
});
return;
}
deleteTemplateMutation.mutate(
{
templateId: template.id,
},
{
onSuccess: () => {
utils.template.getTemplates.invalidate();
setOpen(false);
toast.success(`Template deleted`);
},
}
);
}
const name = templateForm.watch("name");
return (
<Dialog
open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent">
<Trash2 className="h-[18px] w-[18px] text-red-600/80" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Template</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-primary">{template.name}</span>?
You can't reverse this.
</DialogDescription>
</DialogHeader>
<div className="py-2">
<Form {...templateForm}>
<form
onSubmit={templateForm.handleSubmit(onTemplateDelete)}
className="space-y-4"
>
<FormField
control={templateForm.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={
deleteTemplateMutation.isPending || template.name !== name
}
>
{deleteTemplateMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
};
export default DeleteTemplate;

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 { Template } from "@prisma/client";
export const DuplicateTemplate: React.FC<{
template: Partial<Template> & { id: string };
}> = ({ template }) => {
const [open, setOpen] = useState(false);
const duplicateTemplateMutation =
api.template.duplicateTemplate.useMutation();
const utils = api.useUtils();
async function onTemplateDuplicate() {
duplicateTemplateMutation.mutate(
{
templateId: template.id,
},
{
onSuccess: () => {
utils.template.getTemplates.invalidate();
setOpen(false);
toast.success(`Template duplicated`);
},
}
);
}
return (
<Dialog
open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent">
<Copy className="h-[18px] w-[18px] text-blue-600/80" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Duplicate Template</DialogTitle>
<DialogDescription>
Are you sure you want to duplicate{" "}
<span className="font-semibold text-primary">{template.name}</span>?
</DialogDescription>
</DialogHeader>
<div className="py-2">
<div className="flex justify-end">
<Button
onClick={onTemplateDuplicate}
variant="default"
disabled={duplicateTemplateMutation.isPending}
>
{duplicateTemplateMutation.isPending
? "Duplicating..."
: "Duplicate"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
export default DuplicateTemplate;

View File

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

View File

@@ -0,0 +1,113 @@
"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 Spinner from "@unsend/ui/src/spinner";
import { formatDistanceToNow } from "date-fns";
// import DeleteCampaign from "./delete-campaign";
import Link from "next/link";
// import DuplicateCampaign from "./duplicate-campaign";
import { TextWithCopyButton } from "@unsend/ui/src/text-with-copy";
import DeleteTemplate from "./delete-template";
import DuplicateTemplate from "./duplicate-template";
export default function TemplateList() {
const [page, setPage] = useUrlState("page", "1");
const pageNumber = Number(page);
const templateQuery = api.template.getTemplates.useQuery({
page: pageNumber,
});
return (
<div className="mt-10 flex flex-col gap-4">
<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 className="">ID</TableHead>
<TableHead className="">Created At</TableHead>
<TableHead className="rounded-tr-xl">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templateQuery.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>
) : templateQuery.data?.templates.length ? (
templateQuery.data?.templates.map((template) => (
<TableRow key={template.id} className="">
<TableCell className="font-medium">
<Link
className="underline underline-offset-4 decoration-dashed text-foreground hover:text-primary"
href={`/templates/${template.id}`}
>
{template.name}
</Link>
</TableCell>
<TableCell>
<TextWithCopyButton
value={template.id}
className="w-[200px] overflow-hidden"
/>
</TableCell>
<TableCell className="">
{formatDistanceToNow(new Date(template.createdAt), {
addSuffix: true,
})}
</TableCell>
<TableCell>
<div className="flex gap-2">
<DuplicateTemplate template={template} />
<DeleteTemplate template={template} />
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
No templates 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 >= (templateQuery.data?.totalPage ?? 0)}
>
Next
</Button>
</div>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { teamRouter } from "./routers/team";
import { adminRouter } from "./routers/admin"; import { adminRouter } from "./routers/admin";
import { contactsRouter } from "./routers/contacts"; import { contactsRouter } from "./routers/contacts";
import { campaignRouter } from "./routers/campaign"; import { campaignRouter } from "./routers/campaign";
import { templateRouter } from "./routers/template";
/** /**
* This is the primary router for your server. * This is the primary router for your server.
@@ -20,6 +21,7 @@ export const appRouter = createTRPCRouter({
admin: adminRouter, admin: adminRouter,
contacts: contactsRouter, contacts: contactsRouter,
campaign: campaignRouter, campaign: campaignRouter,
template: templateRouter,
}); });
// export type definition of API // export type definition of API

View File

@@ -16,9 +16,8 @@ import {
} from "~/server/service/campaign-service"; } from "~/server/service/campaign-service";
import { validateDomainFromEmail } from "~/server/service/domain-service"; import { validateDomainFromEmail } from "~/server/service/domain-service";
import { import {
DEFAULT_BUCKET,
getDocumentUploadUrl, getDocumentUploadUrl,
isStorageConfigured, isStorageConfigured
} from "~/server/service/storage-service"; } from "~/server/service/storage-service";
const statuses = Object.values(CampaignStatus) as [CampaignStatus]; const statuses = Object.values(CampaignStatus) as [CampaignStatus];

View File

@@ -0,0 +1,170 @@
import { Prisma } from "@prisma/client";
import { TRPCError } from "@trpc/server";
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
import { z } from "zod";
import { env } from "~/env";
import {
teamProcedure,
createTRPCRouter,
templateProcedure
} from "~/server/api/trpc";
import { nanoid } from "~/server/nanoid";
import {
getDocumentUploadUrl,
isStorageConfigured
} from "~/server/service/storage-service";
export const templateRouter = createTRPCRouter({
getTemplates: 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.TemplateFindManyArgs["where"] = {
teamId: team.id,
};
const countP = db.template.count({ where: whereConditions });
const templatesP = db.template.findMany({
where: whereConditions,
select: {
id: true,
name: true,
subject: true,
createdAt: true,
updatedAt: true,
html: true,
},
orderBy: {
createdAt: "desc",
},
skip: offset,
take: limit,
});
const [templates, count] = await Promise.all([templatesP, countP]);
return { templates, totalPage: Math.ceil(count / limit) };
}),
createTemplate: teamProcedure
.input(
z.object({
name: z.string(),
subject: z.string(),
})
)
.mutation(async ({ ctx: { db, team }, input }) => {
const template = await db.template.create({
data: {
...input,
teamId: team.id,
},
});
return template;
}),
updateTemplate: templateProcedure
.input(
z.object({
name: z.string().optional(),
subject: z.string().optional(),
content: z.string().optional(),
})
)
.mutation(async ({ ctx: { db }, input }) => {
const { templateId, ...data } = input;
let html: string | null = null;
if (data.content) {
const jsonContent = data.content ? JSON.parse(data.content) : null;
const renderer = new EmailRenderer(jsonContent);
html = await renderer.render();
}
const template = await db.template.update({
where: { id: templateId },
data: {
...data,
html,
},
});
return template;
}),
deleteTemplate: templateProcedure.mutation(
async ({ ctx: { db, team }, input }) => {
const template = await db.template.delete({
where: { id: input.templateId, teamId: team.id },
});
return template;
}
),
getTemplate: templateProcedure.query(async ({ ctx: { db, team }, input }) => {
const template = await db.template.findUnique({
where: { id: input.templateId, teamId: team.id },
});
if (!template) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Template not found",
});
}
const imageUploadSupported = isStorageConfigured();
return {
...template,
imageUploadSupported,
};
}),
duplicateTemplate: templateProcedure.mutation(
async ({ ctx: { db, team, template }, input }) => {
const newTemplate = await db.template.create({
data: {
name: `${template.name} (Copy)`,
subject: template.subject,
content: template.content,
teamId: team.id
},
});
return newTemplate;
}
),
generateImagePresignedUrl: templateProcedure
.input(
z.object({
name: z.string(),
type: z.string(),
})
)
.mutation(async ({ ctx: { team }, input }) => {
const extension = input.name.split(".").pop();
const randomName = `${nanoid()}.${extension}`;
const url = await getDocumentUploadUrl(
`${team.id}/${randomName}`,
input.type
);
const imageUrl = `${env.S3_COMPATIBLE_PUBLIC_URL}/${team.id}/${randomName}`;
return { uploadUrl: url, imageUrl };
}),
});

View File

@@ -204,6 +204,27 @@ export const campaignProcedure = teamProcedure
return next({ ctx: { ...ctx, campaign } }); return next({ ctx: { ...ctx, campaign } });
}); });
export const templateProcedure = teamProcedure
.input(
z.object({
templateId: z.string(),
})
)
.use(async ({ ctx, next, input }) => {
const template = await db.template.findUnique({
where: { id: input.templateId, teamId: ctx.team.id },
});
if (!template) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Template not found",
});
}
return next({ ctx: { ...ctx, template } });
});
/** /**
* To manage application settings, for hosted version, authenticated users will be considered as admin * To manage application settings, for hosted version, authenticated users will be considered as admin
*/ */

View File

@@ -14,7 +14,9 @@ const route = createRoute({
schema: z.object({ schema: z.object({
to: z.string().or(z.array(z.string())), to: z.string().or(z.array(z.string())),
from: z.string(), from: z.string(),
subject: z.string(), subject: z.string().optional().openapi({ description: 'Optional when templateId is provided' }),
templateId: z.string().optional().openapi({ description: 'ID of a template from the dashboard' }),
variables: z.record(z.string()).optional(),
replyTo: z.string().or(z.array(z.string())).optional(), replyTo: z.string().or(z.array(z.string())).optional(),
cc: z.string().or(z.array(z.string())).optional(), cc: z.string().or(z.array(z.string())).optional(),
bcc: z.string().or(z.array(z.string())).optional(), bcc: z.string().or(z.array(z.string())).optional(),
@@ -29,7 +31,10 @@ const route = createRoute({
) )
.optional(), .optional(),
scheduledAt: z.string().datetime().optional(), scheduledAt: z.string().datetime().optional(),
}), }).refine(
data => !!data.subject || !!data.templateId,
'Either subject or templateId should be passed.',
),
}, },
}, },
}, },

View File

@@ -3,6 +3,7 @@ import { db } from "../db";
import { UnsendApiError } from "~/server/public-api/api-error"; import { UnsendApiError } from "~/server/public-api/api-error";
import { EmailQueueService } from "./email-queue-service"; import { EmailQueueService } from "./email-queue-service";
import { validateDomainFromEmail } from "./domain-service"; import { validateDomainFromEmail } from "./domain-service";
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
async function checkIfValidEmail(emailId: string) { async function checkIfValidEmail(emailId: string) {
const email = await db.email.findUnique({ const email = await db.email.findUnique({
@@ -30,6 +31,14 @@ async function checkIfValidEmail(emailId: string) {
return { email, domain }; return { email, domain };
} }
export const replaceVariables = (text: string, variables: Record<string, string>) => {
return Object.keys(variables).reduce((accum, key) => {
const re = new RegExp(`{{${key}}}`, 'g');
const returnTxt = accum.replace(re, variables[key] as string);
return returnTxt;
}, text);
};
/** /**
Send transactional email Send transactional email
*/ */
@@ -39,9 +48,11 @@ export async function sendEmail(
const { const {
to, to,
from, from,
subject, subject: subjectFromApiCall,
templateId,
variables,
text, text,
html, html: htmlFromApiCall,
teamId, teamId,
attachments, attachments,
replyTo, replyTo,
@@ -49,9 +60,28 @@ export async function sendEmail(
bcc, bcc,
scheduledAt, scheduledAt,
} = emailContent; } = emailContent;
let subject = subjectFromApiCall;
let html = htmlFromApiCall;
const domain = await validateDomainFromEmail(from, teamId); const domain = await validateDomainFromEmail(from, teamId);
if (templateId) {
const template = await db.template.findUnique({
where: { id: templateId },
});
if (template) {
const jsonContent = JSON.parse(template.content || "{}");
const renderer = new EmailRenderer(jsonContent);
subject = replaceVariables(template.subject || '', variables || {});
html = await renderer.render({
shouldReplaceVariableValues: true,
variableValues: variables,
});
}
}
const scheduledAtDate = scheduledAt ? new Date(scheduledAt) : undefined; const scheduledAtDate = scheduledAt ? new Date(scheduledAt) : undefined;
const delay = scheduledAtDate const delay = scheduledAtDate
? Math.max(0, scheduledAtDate.getTime() - Date.now()) ? Math.max(0, scheduledAtDate.getTime() - Date.now())
@@ -61,7 +91,7 @@ export async function sendEmail(
data: { data: {
to: Array.isArray(to) ? to : [to], to: Array.isArray(to) ? to : [to],
from, from,
subject, subject: subject as string,
replyTo: replyTo replyTo: replyTo
? Array.isArray(replyTo) ? Array.isArray(replyTo)
? replyTo ? replyTo

View File

@@ -1,7 +1,9 @@
export type EmailContent = { export type EmailContent = {
to: string | string[]; to: string | string[];
from: string; from: string;
subject: string; subject?: string;
templateId?: string;
variables?: Record<string, string>,
text?: string; text?: string;
html?: string; html?: string;
replyTo?: string | string[]; replyTo?: string | string[];