-
Notifications
You must be signed in to change notification settings - Fork 12
Mikec/fixing handle email event issue #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
commit: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR fixes a critical bug in the handleEmailEvent function where invalid webhook events from Resend could cause the function to fail with "unique() query returned more than one result" errors. The fix introduces early event validation and parsing before attempting to query the database.
- Adds early validation and parsing of webhook events to prevent invalid data from causing database query failures
- Refactors the event handling logic to use functional programming patterns and exhaustive type checking
- Implements comprehensive test coverage for the
handleEmailEventfunction including edge cases
Reviewed Changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/component/utils.ts | New utility functions for parsing validation and exhaustive type checking |
| src/component/shared.ts | Added type utilities for EmailEvent variants |
| src/component/lib.ts | Refactored handleEmailEvent with early validation and functional programming approach |
| src/component/lib.test.ts | Comprehensive test suite for handleEmailEvent function |
| src/component/setup.test.ts | Test utilities and helpers for creating test data |
| src/client/index.ts | Added UsedAPI type export |
| src/client/setup.test.ts | Test setup utilities for client-side testing |
| src/client/index.test.ts | Basic client test structure |
| src/client/_generated/_ignore.ts | Placeholder file for convex-test detection |
| package.json | Updated svix dependency version |
| // NOOP -- we do this automatically when we send the email. | ||
| if (event.type == "email.sent") return null; | ||
|
|
||
| // These we dont do anything with |
Copilot
AI
Aug 5, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The email.clicked event is ignored without any handling or logging. The original code had a comment explaining this was because 'One email can have multiple clicks, so we don't track them for now.' Consider adding this explanatory comment back to maintain code clarity.
| // These we dont do anything with | |
| // These we dont do anything with | |
| // One email can have multiple clicks, so we don't track them for now. |
| opened: false, | ||
| resendId: "test-resend-id-123", | ||
| segment: 1, | ||
| finalizedAt: Number.MAX_SAFE_INTEGER, // FINALIZED_EPOCH |
Copilot
AI
Aug 5, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using Number.MAX_SAFE_INTEGER as a magic number without defining it as a named constant reduces code readability. Consider defining FINALIZED_EPOCH as a constant or importing it from wherever it's defined in the codebase.
| finalizedAt: Number.MAX_SAFE_INTEGER, // FINALIZED_EPOCH | |
| finalizedAt: FINALIZED_EPOCH, |
src/client/index.ts
Outdated
| type RunQueryCtx, | ||
| } from "../component/shared.js"; | ||
|
|
||
| export type UsedAPI = UseApi<typeof api>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| export type UsedAPI = UseApi<typeof api>; | |
| export type ResendComponent = UseApi<typeof api>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
then we can use it below too to be more descriptive
| const result = attemptToParse(vEmailEvent, args.event); | ||
| if (result.kind === "error") | ||
| return console.warn( | ||
| `Invalid email event received. You might want to to exclude this event from your Resend webhook settings in the Resend dashboard. ${result.error}.` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's capture the event, either in a separate console logs or dump to a table - it'd be a big shame to have folks lose data b/c resend changed some data format. One thing I did for workpool (I think) was to have a table of error data that I inserted into when I got a validation error on onComplete - so they could debug and possibly copy out / restore data.
Probably overly paranoid here since it's a webhook - so the data is likely still in Resend..
src/component/lib.ts
Outdated
| if (!email) | ||
| return console.info( | ||
| `Email not found for resendId: ${event.data.email_id}, ignoring...` | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| if (!email) | |
| return console.info( | |
| `Email not found for resendId: ${event.data.email_id}, ignoring...` | |
| ); | |
| if (!email) { | |
| console.info( | |
| `Email not found for resendId: ${event.data.email_id}, ignoring...` | |
| ); | |
| return; | |
| } |
I'm not a dentist but.. seeing return console.info confused me
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
haha I just like to compress the lines but sure ill make that change
| throw new Error(`Unhandled event type: ${value as string}`); | ||
| }; | ||
|
|
||
| export const iife = <T>(fn: () => T): T => fn(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what the heckie is this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
as mentioned in the DMs its "immediately invoking function expression" its basically a way to define an anonymous function inline and immediatelly call it
| finalizedAt: Date.now(), | ||
| }; | ||
|
|
||
| if (event.type == "email.bounced") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
any reson not to chain them as else if's so it's clear that we don't expect any combination to happen?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You dont need an else if you are doing the early-return pattern like this. Thats kind of the whole reason I do it like this is to avoid the mental overhead of nested chains of if-else. The idea is to only ever keep the logic one level deep.
The iife could well be another function but sometimes its easier to just inline it and use an iife
| opened: true, | ||
| }; | ||
|
|
||
| assertExhaustive(event); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not familiar with this pattern overall - to me it reads like a switch but a bit more verbose. especially if the "default" case is to throw, and each case results in exactly one thing - when is this pattern typically used? Where did you pick it up from?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is basically exhaustive pattern matching. I could have pulled in https://github.com/gvergnaud/ts-pattern and I do use that in other projects but IMO its not needed here.
I really like this pattern as it means that if we add another email type in the future we will get a typescript error forcing us to explicitly handle it here.
Its kind of the same pattern that type-route employs for routing: https://type-route.zilch.dev/introduction/getting-started.html#step-3-display-current-route
I personally love it and have been using it for years to make what I think is really readable and maintainable code that leverages the TS compiler as much as possible to catch future whoopsies.
Happy to chat about it more if you want?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
gotcha - yeah I've seen different variants on the theme. I have a personal bias towards switch w/ the assertExhaustive in the default case, now that typescript is smart enough to handle that, but mostly personal preference.
|
It wouldn't be allowed to pass through. I think in these cases the user can
request it and we can add it to the validator. They would be explicitly
adding it or setting up a new integration so shouldn't break existing apps
…On Mon, Aug 4, 2025 at 11:39 PM Mike Cann ***@***.***> wrote:
*mikecann* left a comment (get-convex/resend#30)
<#30 (comment)>
I just realised there might be a potential issue with this. If Resend add
another email "type" then the attemptToParse at the top will fail when it
should still just allow it to continue but warn later.. hmmm.. ill write a
test to cover this and fix it
—
Reply to this email directly, view it on GitHub
<#30 (comment)>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AACZQW6DOBUKYE5JK3EPLYL3MBGRDAVCNFSM6AAAAACDD6YWW6VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZTCNJTGY4TOOJVGY>
.
You are receiving this because your review was requested.Message ID:
***@***.***>
|
|
Ye @ianmacartney I think you are referencing a comment that I subsequently deleted as I realised that indeed that was the case. |
|
|
This PR fixes an issue that was raised by a user on discord: https://discord.com/channels/1019350475847499849/1401189654748729364/1401189654748729364
So the issue is that
handleEmailEventtakes in aneventtyped toanybut then trusts that it is a validEmailEvent. This might not be the case if the user chooses to send events other than Email events to us.To fix this I pulled up the parse higher in the
handleEmailEventso that it now attempts the parse early and bombs out if it not the shape we are expecting. Before it exits it leaves the user with a nice warning in their logs to let them know they might want to exclude that event from being sent in the Resend dashboard.While I was here I decided to tidy up the function and implemented a bunch of tests around it to make sure there are not other issues in this function.