diff --git a/src/client/index.ts b/src/client/index.ts index cc40cec..ed040f6 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -30,6 +30,7 @@ function getDefaultConfig(): Config { initialBackoffMs: 30000, retryAttempts: 5, testMode: true, + handleClick: false, }; } @@ -78,6 +79,12 @@ export type ResendOptions = { event: EmailEvent; } > | null; + + /** + * Whether to store email.clicked events. + * Default: false + */ + handleClick?: boolean; }; async function configToRuntimeConfig( @@ -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; diff --git a/src/component/lib.ts b/src/component/lib.ts index 3bea8e8..fdbb429 100644 --- a/src/component/lib.ts +++ b/src/component/lib.ts @@ -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"; @@ -127,6 +132,7 @@ export const sendEmail = mutation({ status: "waiting", complained: false, opened: false, + clicks: [], replyTo: args.replyTo ?? [], finalizedAt: FINALIZED_EPOCH, }); @@ -537,6 +543,7 @@ export const handleEmailEvent = mutation({ type: event.type, data: { email_id: resendId, + click: event.data?.click, }, }; let changed = true; @@ -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; diff --git a/src/component/schema.ts b/src/component/schema.ts index 5a0a9e4..eb8eb3a 100644 --- a/src/component/schema.ts +++ b/src/component/schema.ts @@ -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(), @@ -30,6 +38,7 @@ export default defineSchema({ }) ) ), + clicks: v.array(v.id("emailClicks")), status: vStatus, errorMessage: v.optional(v.string()), complained: v.boolean(), diff --git a/src/component/shared.ts b/src/component/shared.ts index f80cbb6..858ec67 100644 --- a/src/component/shared.ts +++ b/src/component/shared.ts @@ -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; +export const vClickEvent = v.object({ + ipAddress: v.string(), + link: v.string(), + timestamp: v.string(), + userAgent: v.string(), +}); + +export type ClickEvent = Infer; + // Normalized webhook events coming from Resend. export const vEmailEvent = v.object({ type: v.string(), @@ -43,6 +53,7 @@ export const vEmailEvent = v.object({ message: v.optional(v.string()), }) ), + click: v.optional(vClickEvent), }), }); export type EmailEvent = Infer;