Skip to content

Commit 97f77e6

Browse files
committed
Add support for template-based emails in sendEmail function
- Introduced `sendWithTemplate` action to handle sending emails with templates. - Updated `sendEmail` function to accept both traditional and template-based email formats. - Enhanced validation to ensure either content or template is provided, but not both. - Added tests for template email functionality, including acceptance and rejection scenarios. - Updated schema and shared types to include template-related fields.
1 parent 29916d7 commit 97f77e6

File tree

7 files changed

+312
-54
lines changed

7 files changed

+312
-54
lines changed

example/convex/example.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,40 @@ export const sendOne = internalAction({
8383
},
8484
});
8585

86+
export const sendWithTemplate = internalAction({
87+
args: {
88+
to: v.optional(v.string()),
89+
templateId: v.string(),
90+
subject: v.optional(v.string()),
91+
},
92+
returns: v.string(),
93+
handler: async (ctx, args) => {
94+
const email = await resend.sendEmail(ctx, {
95+
from: "onboarding@resend.dev",
96+
to: args.to ?? "delivered@resend.dev",
97+
subject: args.subject, // Optional: override template's default subject
98+
template: {
99+
id: args.templateId,
100+
variables: {
101+
PRODUCT: "Vintage Macintosh",
102+
PRICE: 499,
103+
},
104+
},
105+
});
106+
console.log("Email with template sent", email);
107+
let status = await resend.status(ctx, email);
108+
while (
109+
status &&
110+
(status.status === "queued" || status.status === "waiting")
111+
) {
112+
await new Promise((resolve) => setTimeout(resolve, 1000));
113+
status = await resend.status(ctx, email);
114+
}
115+
console.log("Email status", status);
116+
return email;
117+
},
118+
});
119+
86120
export const insertExpectation = internalMutation({
87121
args: {
88122
email: v.string(),

src/client/index.ts

Lines changed: 82 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,13 @@ export type ResendComponent = ComponentApi;
2222

2323
export type EmailId = string & { __isEmailId: true };
2424
export const vEmailId = v.string() as VString<EmailId>;
25-
export { vEmailEvent, vOptions, vStatus } from "../component/shared.js";
26-
export type { EmailEvent, Status } from "../component/shared.js";
25+
export {
26+
vEmailEvent,
27+
vOptions,
28+
vStatus,
29+
vTemplate,
30+
} from "../component/shared.js";
31+
export type { EmailEvent, Status, Template } from "../component/shared.js";
2732
export const vOnEmailEventArgs = v.object({
2833
id: vEmailId,
2934
event: vEmailEvent,
@@ -161,17 +166,31 @@ export type EmailStatus = {
161166
clicked: boolean;
162167
};
163168

164-
export type SendEmailOptions = {
165-
from: string;
166-
to: string | string[];
167-
cc?: string | string[];
168-
bcc?: string | string[];
169-
subject: string;
170-
html?: string;
171-
text?: string;
172-
replyTo?: string[];
173-
headers?: { name: string; value: string }[];
174-
};
169+
export type SendEmailOptions =
170+
| {
171+
from: string;
172+
to: string | string[];
173+
cc?: string | string[];
174+
bcc?: string | string[];
175+
subject: string;
176+
html?: string;
177+
text?: string;
178+
replyTo?: string[];
179+
headers?: { name: string; value: string }[];
180+
}
181+
| {
182+
from: string;
183+
to: string | string[];
184+
cc?: string | string[];
185+
bcc?: string | string[];
186+
subject?: string;
187+
template: {
188+
id: string;
189+
variables: Record<string, string | number>;
190+
};
191+
replyTo?: string[];
192+
headers?: { name: string; value: string }[];
193+
};
175194

176195
export class Resend {
177196
public config: Config;
@@ -283,23 +302,57 @@ export class Resend {
283302

284303
if (this.config.apiKey === "") throw new Error("API key is not set");
285304

286-
const id = await ctx.runMutation(this.component.lib.sendEmail, {
287-
options: await configToRuntimeConfig(this.config, this.onEmailEvent),
288-
...sendEmailArgs,
289-
to:
290-
typeof sendEmailArgs.to === "string"
291-
? [sendEmailArgs.to]
292-
: sendEmailArgs.to,
293-
cc: toArray(sendEmailArgs.cc),
294-
bcc: toArray(sendEmailArgs.bcc),
295-
});
296-
297-
return id as EmailId;
305+
// Prepare the mutation args based on whether it's a template or traditional email
306+
if ("template" in sendEmailArgs) {
307+
// Template-based email
308+
const id = await ctx.runMutation(this.component.lib.sendEmail, {
309+
options: await configToRuntimeConfig(this.config, this.onEmailEvent),
310+
from: sendEmailArgs.from,
311+
to:
312+
typeof sendEmailArgs.to === "string"
313+
? [sendEmailArgs.to]
314+
: sendEmailArgs.to,
315+
cc: toArray(sendEmailArgs.cc),
316+
bcc: toArray(sendEmailArgs.bcc),
317+
subject: sendEmailArgs.subject,
318+
replyTo: sendEmailArgs.replyTo,
319+
headers: sendEmailArgs.headers,
320+
templateId: sendEmailArgs.template.id,
321+
templateVariables: JSON.stringify(sendEmailArgs.template.variables),
322+
});
323+
return id as EmailId;
324+
} else {
325+
// Traditional email
326+
const id = await ctx.runMutation(this.component.lib.sendEmail, {
327+
options: await configToRuntimeConfig(this.config, this.onEmailEvent),
328+
from: sendEmailArgs.from,
329+
to:
330+
typeof sendEmailArgs.to === "string"
331+
? [sendEmailArgs.to]
332+
: sendEmailArgs.to,
333+
cc: toArray(sendEmailArgs.cc),
334+
bcc: toArray(sendEmailArgs.bcc),
335+
replyTo: sendEmailArgs.replyTo,
336+
headers: sendEmailArgs.headers,
337+
subject: sendEmailArgs.subject,
338+
html: sendEmailArgs.html,
339+
text: sendEmailArgs.text,
340+
});
341+
return id as EmailId;
342+
}
298343
}
299344

300345
async sendEmailManually(
301346
ctx: RunMutationCtx,
302-
options: Omit<SendEmailOptions, "html" | "text">,
347+
options: {
348+
from: string;
349+
to: string | string[];
350+
cc?: string | string[];
351+
bcc?: string | string[];
352+
subject: string;
353+
replyTo?: string[];
354+
headers?: { name: string; value: string }[];
355+
},
303356
sendCallback: (emailId: EmailId) => Promise<string>,
304357
): Promise<EmailId> {
305358
const emailId = (await ctx.runMutation(
@@ -383,7 +436,7 @@ export class Resend {
383436
): Promise<{
384437
from: string;
385438
to: string[];
386-
subject: string;
439+
subject?: string;
387440
replyTo: string[];
388441
headers?: { name: string; value: string }[];
389442
status: Status;
@@ -399,6 +452,8 @@ export class Resend {
399452
createdAt: number;
400453
html?: string;
401454
text?: string;
455+
templateId?: string;
456+
templateVariables?: string;
402457
} | null> {
403458
return await ctx.runQuery(this.component.lib.get, {
404459
emailId,

src/component/_generated/component.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,9 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
8989
| "delivery_delayed"
9090
| "bounced"
9191
| "failed";
92-
subject: string;
92+
subject?: string;
93+
templateId?: string;
94+
templateVariables?: string;
9395
text?: string;
9496
to: Array<string>;
9597
} | null,
@@ -143,7 +145,9 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
143145
testMode: boolean;
144146
};
145147
replyTo?: Array<string>;
146-
subject: string;
148+
subject?: string;
149+
templateId?: string;
150+
templateVariables?: string;
147151
text?: string;
148152
to: Array<string>;
149153
},

src/component/lib.test.ts

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
setupTestLastOptions,
99
type Tester,
1010
} from "./setup.test.js";
11-
import { type Doc } from "./_generated/dataModel.js";
11+
import { type Doc, type Id } from "./_generated/dataModel.js";
1212

1313
describe("handleEmailEvent", () => {
1414
let t: Tester;
@@ -248,3 +248,121 @@ describe("handleEmailEvent", () => {
248248
expect(updatedEmail.opened).toBe(false);
249249
});
250250
});
251+
252+
describe("sendEmail with templates", () => {
253+
let t: Tester;
254+
255+
beforeEach(async () => {
256+
t = setupTest();
257+
await setupTestLastOptions(t);
258+
});
259+
260+
it("should accept template-based email", async () => {
261+
const emailId: Id<"emails"> = await t.mutation(api.lib.sendEmail, {
262+
options: {
263+
apiKey: "test-key",
264+
initialBackoffMs: 1000,
265+
retryAttempts: 3,
266+
testMode: true,
267+
},
268+
from: "test@resend.dev",
269+
to: ["delivered@resend.dev"],
270+
templateId: "order-confirmation",
271+
templateVariables: JSON.stringify({
272+
PRODUCT: "Vintage Macintosh",
273+
PRICE: 499,
274+
}),
275+
});
276+
277+
const email = await t.run(async (ctx) => {
278+
const _email = await ctx.db.get(emailId);
279+
if (!_email) throw new Error("Email not found");
280+
return _email;
281+
});
282+
283+
expect(email.templateId).toBe("order-confirmation");
284+
expect(email.templateVariables).toBe(
285+
JSON.stringify({ PRODUCT: "Vintage Macintosh", PRICE: 499 }),
286+
);
287+
expect(email.subject).toBeUndefined();
288+
expect(email.html).toBeUndefined();
289+
expect(email.text).toBeUndefined();
290+
expect(email.status).toBe("waiting");
291+
});
292+
293+
it("should reject email with both template and html/text", async () => {
294+
await expect(
295+
t.mutation(api.lib.sendEmail, {
296+
options: {
297+
apiKey: "test-key",
298+
initialBackoffMs: 1000,
299+
retryAttempts: 3,
300+
testMode: true,
301+
},
302+
from: "test@resend.dev",
303+
to: ["delivered@resend.dev"],
304+
subject: "Test",
305+
html: "<p>Test</p>",
306+
templateId: "order-confirmation",
307+
templateVariables: JSON.stringify({ PRODUCT: "Test" }),
308+
}),
309+
).rejects.toThrow("Cannot provide both html/text and template");
310+
});
311+
312+
it("should accept template email with optional subject", async () => {
313+
const emailId: Id<"emails"> = await t.mutation(api.lib.sendEmail, {
314+
options: {
315+
apiKey: "test-key",
316+
initialBackoffMs: 1000,
317+
retryAttempts: 3,
318+
testMode: true,
319+
},
320+
from: "test@resend.dev",
321+
to: ["delivered@resend.dev"],
322+
subject: "Custom Subject Override",
323+
templateId: "order-confirmation",
324+
templateVariables: JSON.stringify({ PRODUCT: "Test" }),
325+
});
326+
327+
const email = await t.run(async (ctx) => {
328+
const _email = await ctx.db.get(emailId);
329+
if (!_email) throw new Error("Email not found");
330+
return _email;
331+
});
332+
333+
expect(email.templateId).toBe("order-confirmation");
334+
expect(email.subject).toBe("Custom Subject Override");
335+
expect(email.status).toBe("waiting");
336+
});
337+
338+
it("should reject email without content or template", async () => {
339+
await expect(
340+
t.mutation(api.lib.sendEmail, {
341+
options: {
342+
apiKey: "test-key",
343+
initialBackoffMs: 1000,
344+
retryAttempts: 3,
345+
testMode: true,
346+
},
347+
from: "test@resend.dev",
348+
to: ["delivered@resend.dev"],
349+
}),
350+
).rejects.toThrow("Either html/text or template must be provided");
351+
});
352+
353+
it("should reject traditional email without subject", async () => {
354+
await expect(
355+
t.mutation(api.lib.sendEmail, {
356+
options: {
357+
apiKey: "test-key",
358+
initialBackoffMs: 1000,
359+
retryAttempts: 3,
360+
testMode: true,
361+
},
362+
from: "test@resend.dev",
363+
to: ["delivered@resend.dev"],
364+
html: "<p>Test</p>",
365+
}),
366+
).rejects.toThrow("Subject is required when not using a template");
367+
});
368+
});

0 commit comments

Comments
 (0)