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

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

View File

@@ -16,9 +16,8 @@ import {
} from "~/server/service/campaign-service";
import { validateDomainFromEmail } from "~/server/service/domain-service";
import {
DEFAULT_BUCKET,
getDocumentUploadUrl,
isStorageConfigured,
isStorageConfigured
} from "~/server/service/storage-service";
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 } });
});
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
*/

View File

@@ -14,7 +14,9 @@ const route = createRoute({
schema: z.object({
to: z.string().or(z.array(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(),
cc: 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(),
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 { EmailQueueService } from "./email-queue-service";
import { validateDomainFromEmail } from "./domain-service";
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
async function checkIfValidEmail(emailId: string) {
const email = await db.email.findUnique({
@@ -30,6 +31,14 @@ async function checkIfValidEmail(emailId: string) {
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
*/
@@ -39,9 +48,11 @@ export async function sendEmail(
const {
to,
from,
subject,
subject: subjectFromApiCall,
templateId,
variables,
text,
html,
html: htmlFromApiCall,
teamId,
attachments,
replyTo,
@@ -49,9 +60,28 @@ export async function sendEmail(
bcc,
scheduledAt,
} = emailContent;
let subject = subjectFromApiCall;
let html = htmlFromApiCall;
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 delay = scheduledAtDate
? Math.max(0, scheduledAtDate.getTime() - Date.now())
@@ -61,7 +91,7 @@ export async function sendEmail(
data: {
to: Array.isArray(to) ? to : [to],
from,
subject,
subject: subject as string,
replyTo: replyTo
? Array.isArray(replyTo)
? replyTo