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: 8 additions & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function getDefaultConfig(): Config {
initialBackoffMs: 30000,
retryAttempts: 5,
testMode: true,
handleClick: false,
};
}

Expand Down Expand Up @@ -78,6 +79,12 @@ export type ResendOptions = {
event: EmailEvent;
}
> | null;

/**
* Whether to store email.clicked events.
* Default: false
*/
handleClick?: boolean;
};

async function configToRuntimeConfig(
Expand Down Expand Up @@ -171,6 +178,7 @@ export class Resend {
options?.initialBackoffMs ?? defaultConfig.initialBackoffMs,
retryAttempts: options?.retryAttempts ?? defaultConfig.retryAttempts,
testMode: options?.testMode ?? defaultConfig.testMode,
handleClick: options?.handleClick ?? defaultConfig.handleClick,
};
if (options?.onEmailEvent) {
this.onEmailEvent = options.onEmailEvent;
Expand Down
37 changes: 33 additions & 4 deletions src/component/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ import { RateLimiter } from "@convex-dev/rate-limiter";
import { components, internal } from "./_generated/api.js";
import { internalMutation } from "./_generated/server.js";
import { type Id, type Doc } from "./_generated/dataModel.js";
import { type RuntimeConfig, vOptions, vStatus } from "./shared.js";
import {
type ClickEvent,
type RuntimeConfig,
vOptions,
vStatus,
} from "./shared.js";
import { type FunctionHandle } from "convex/server";
import { type EmailEvent, type RunMutationCtx } from "./shared.js";
import { isDeepEqual } from "remeda";
Expand Down Expand Up @@ -127,6 +132,7 @@ export const sendEmail = mutation({
status: "waiting",
complained: false,
opened: false,
clicks: [],
replyTo: args.replyTo ?? [],
finalizedAt: FINALIZED_EPOCH,
});
Expand Down Expand Up @@ -537,6 +543,7 @@ export const handleEmailEvent = mutation({
type: event.type,
data: {
email_id: resendId,
click: event.data?.click,
},
};
let changed = true;
Expand Down Expand Up @@ -566,10 +573,32 @@ export const handleEmailEvent = mutation({
case "email.opened":
email.opened = true;
break;
case "email.clicked":
changed = false;
// One email can have multiple clicks, so we don't track them for now.
case "email.clicked": {
const lastOptions = await ctx.db.query("lastOptions").unique();
if (!lastOptions) {
throw new Error("No last options found -- invariant");
}

const hasHandleClickEnabled = lastOptions.options.handleClick;
const clickData = cleanedEvent.data?.click;

if (!hasHandleClickEnabled || !clickData) {
changed = false;
break;
}

const clickId = await ctx.db.insert("emailClicks", {
emailId: email._id,
ipAddress: clickData?.ipAddress ?? "",
link: clickData?.link ?? "",
timestamp: clickData?.timestamp ?? new Date().toISOString(),
userAgent: clickData?.userAgent ?? "",
});

email.clicks.push(clickId);

break;
}
default:
// Ignore other events
return;
Expand Down
9 changes: 9 additions & 0 deletions src/component/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ export default defineSchema({
lastOptions: defineTable({
options: vOptions,
}),
emailClicks: defineTable({
emailId: v.id("emails"),
ipAddress: v.string(),
link: v.string(),
timestamp: v.string(),
userAgent: v.string(),
})
.index("by_emailId", ["emailId"]),
emails: defineTable({
from: v.string(),
to: v.string(),
Expand All @@ -30,6 +38,7 @@ export default defineSchema({
})
)
),
clicks: v.array(v.id("emailClicks")),
status: vStatus,
errorMessage: v.optional(v.string()),
complained: v.boolean(),
Expand Down
11 changes: 11 additions & 0 deletions src/component/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,20 @@ export const vOptions = v.object({
apiKey: v.string(),
testMode: v.boolean(),
onEmailEvent: v.optional(onEmailEvent),
handleClick: v.optional(v.boolean()),
});

export type RuntimeConfig = Infer<typeof vOptions>;

export const vClickEvent = v.object({
ipAddress: v.string(),
link: v.string(),
timestamp: v.string(),
userAgent: v.string(),
});

export type ClickEvent = Infer<typeof vClickEvent>;

// Normalized webhook events coming from Resend.
export const vEmailEvent = v.object({
type: v.string(),
Expand All @@ -43,6 +53,7 @@ export const vEmailEvent = v.object({
message: v.optional(v.string()),
})
),
click: v.optional(vClickEvent),
}),
});
export type EmailEvent = Infer<typeof vEmailEvent>;
Expand Down