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:
@@ -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;
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
218
apps/web/src/app/(dashboard)/templates/[templateId]/page.tsx
Normal file
218
apps/web/src/app/(dashboard)/templates/[templateId]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
145
apps/web/src/app/(dashboard)/templates/create-template.tsx
Normal file
145
apps/web/src/app/(dashboard)/templates/create-template.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
134
apps/web/src/app/(dashboard)/templates/delete-template.tsx
Normal file
134
apps/web/src/app/(dashboard)/templates/delete-template.tsx
Normal 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;
|
@@ -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;
|
16
apps/web/src/app/(dashboard)/templates/page.tsx
Normal file
16
apps/web/src/app/(dashboard)/templates/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
113
apps/web/src/app/(dashboard)/templates/template-list.tsx
Normal file
113
apps/web/src/app/(dashboard)/templates/template-list.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@@ -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
|
||||||
|
@@ -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];
|
||||||
|
170
apps/web/src/server/api/routers/template.ts
Normal file
170
apps/web/src/server/api/routers/template.ts
Normal 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 };
|
||||||
|
}),
|
||||||
|
});
|
@@ -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
|
||||||
*/
|
*/
|
||||||
|
@@ -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.',
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@@ -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
|
||||||
|
@@ -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[];
|
||||||
|
Reference in New Issue
Block a user