Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions example/convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export declare const components: {
"internal",
{ emailId: string },
{
bcc?: Array<string>;
cc?: Array<string>;
complained: boolean;
createdAt: number;
errorMessage?: string;
Expand All @@ -90,7 +92,7 @@ export declare const components: {
| "failed";
subject: string;
text?: string;
to: string;
to: Array<string>;
} | null
>;
getStatus: FunctionReference<
Expand Down Expand Up @@ -122,6 +124,8 @@ export declare const components: {
"mutation",
"internal",
{
bcc?: Array<string>;
cc?: Array<string>;
from: string;
headers?: Array<{ name: string; value: string }>;
html?: string;
Expand All @@ -135,7 +139,7 @@ export declare const components: {
replyTo?: Array<string>;
subject: string;
text?: string;
to: string;
to: Array<string>;
},
string
>;
Expand Down
7 changes: 6 additions & 1 deletion example/convex/example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ export const sendOne = internalAction({
handler: async (ctx, args) => {
const email = await resend.sendEmail(ctx, {
from: "<your-verified-sender-address>",
to: args.to ?? "[email protected]",
to: args.to ?? [
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
],
subject: "Test Email",
html: "This is a test email",
});
Expand Down
19 changes: 16 additions & 3 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,9 @@ export type EmailStatus = {

export type SendEmailOptions = {
from: string;
to: string;
to: string | string[];
cc?: string | string[];
bcc?: string | string[];
subject: string;
html?: string;
text?: string;
Expand Down Expand Up @@ -241,7 +243,7 @@ export class Resend {
replyTo?: string[],
headers?: { name: string; value: string }[]
) {
const sendEmailArgs =
const sendEmailArgs: SendEmailOptions =
typeof fromOrOptions === "string"
? {
from: fromOrOptions,
Expand All @@ -259,6 +261,12 @@ export class Resend {
const id = await ctx.runMutation(this.component.lib.sendEmail, {
options: await configToRuntimeConfig(this.config, this.onEmailEvent),
...sendEmailArgs,
to:
typeof sendEmailArgs.to === "string"
? [sendEmailArgs.to]
: sendEmailArgs.to,
cc: toArray(sendEmailArgs.cc),
bcc: toArray(sendEmailArgs.bcc),
});

return id as EmailId;
Expand Down Expand Up @@ -309,7 +317,7 @@ export class Resend {
emailId: EmailId
): Promise<{
from: string;
to: string;
to: string[];
subject: string;
replyTo: string[];
headers?: { name: string; value: string }[];
Expand Down Expand Up @@ -416,3 +424,8 @@ export type UseApi<API> = Expand<{
>
: UseApi<API[mod]>;
}>;

function toArray<T>(value: T | T[] | undefined): T[] | undefined {
if (value === undefined) return undefined;
return Array.isArray(value) ? value : [value];
}
48 changes: 36 additions & 12 deletions src/component/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,23 @@ const FINALIZED_EMAIL_RETENTION_MS = 1000 * 60 * 60 * 24 * 7; // 7 days
const FINALIZED_EPOCH = Number.MAX_SAFE_INTEGER;
const ABANDONED_EMAIL_RETENTION_MS = 1000 * 60 * 60 * 24 * 30; // 30 days

const RESEND_TEST_EMAILS = new Set([
"[email protected]",
"[email protected]",
"[email protected]",
]);
const RESEND_TEST_EMAILS = ["delivered", "bounced", "complained"];

function isTestEmail(email: string) {
const [prefix, domain] = email.split("@");
if (domain !== "resend.dev") {
return false;
}
for (const testEmail of RESEND_TEST_EMAILS) {
if (prefix === testEmail) {
return true;
}
if (prefix.startsWith(testEmail + "+")) {
return true;
}
}
return false;
}

const PERMANENT_ERROR_CODES = new Set([
400, 401 /* 402 not included - unclear spec */, 403, 404, 405, 406, 407, 408,
Expand Down Expand Up @@ -82,7 +94,9 @@ export const sendEmail = mutation({
args: {
options: vOptions,
from: v.string(),
to: v.string(),
to: v.array(v.string()),
cc: v.optional(v.array(v.string())),
bcc: v.optional(v.array(v.string())),
subject: v.string(),
html: v.optional(v.string()),
text: v.optional(v.string()),
Expand All @@ -99,10 +113,14 @@ export const sendEmail = mutation({
returns: v.id("emails"),
handler: async (ctx, args) => {
// We only allow test emails in test mode.
if (args.options.testMode && !RESEND_TEST_EMAILS.has(args.to)) {
throw new Error(
`Test mode is enabled, but email address is not a valid resend test address. Did you want to set testMode: false in your ResendOptions?`
);
if (args.options.testMode) {
for (const to of [...args.to, ...(args.cc ?? []), ...(args.bcc ?? [])]) {
if (!isTestEmail(to)) {
throw new Error(
`Test mode is enabled, but email address is not a valid resend test address. Did you want to set testMode: false in your ResendOptions?`
);
}
}
}

// We require either html or text to be provided. No body = no bueno.
Expand Down Expand Up @@ -136,6 +154,8 @@ export const sendEmail = mutation({
const emailId = await ctx.db.insert("emails", {
from: args.from,
to: args.to,
cc: args.cc,
bcc: args.bcc,
subject: args.subject,
html: htmlContentId,
text: textContentId,
Expand Down Expand Up @@ -215,6 +235,7 @@ export const get = query({
createdAt: v.number(),
html: v.optional(v.string()),
text: v.optional(v.string()),
to: v.array(v.string()),
}),
v.null()
),
Expand All @@ -234,6 +255,7 @@ export const get = query({
createdAt: email._creationTime,
html,
text,
to: Array.isArray(email.to) ? email.to : [email.to],
};
},
});
Expand Down Expand Up @@ -542,11 +564,13 @@ async function createResendBatchPayload(
// Build payload for resend API.
const batchPayload = emails.map((email: Doc<"emails">) => ({
from: email.from,
to: [email.to],
to: Array.isArray(email.to) ? email.to : [email.to],
subject: email.subject,
bcc: email.bcc,
cc: email.cc,
html: email.html ? contentMap.get(email.html) : undefined,
text: email.text ? contentMap.get(email.text) : undefined,
reply_to: email.replyTo && email.replyTo.length ? email.replyTo : undefined,
reply_to: email.replyTo ? email.replyTo : undefined,
headers: email.headers
? Object.fromEntries(
email.headers.map((h: { name: string; value: string }) => [
Expand Down
4 changes: 3 additions & 1 deletion src/component/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ export default defineSchema({
}),
emails: defineTable({
from: v.string(),
to: v.string(),
to: v.union(v.array(v.string()), v.string()),
cc: v.optional(v.array(v.string())),
bcc: v.optional(v.array(v.string())),
subject: v.string(),
replyTo: v.array(v.string()),
html: v.optional(v.id("content")),
Expand Down