Skip to content

Conversation

@mikecann
Copy link
Contributor

@mikecann mikecann commented Aug 5, 2025

This PR fixes an issue that was raised by a user on discord: https://discord.com/channels/1019350475847499849/1401189654748729364/1401189654748729364

8/2/2025, 6:32:48 PM [CONVEX M(lib:handleEmailEvent)] Uncaught Error: unique() query returned more than one result from table emails:
[j57chbeghegwtj8yedvha2xgzn7mxp76, j57f4d10sj6g3kjzn9kk5sb88d7mw76d, ...]
at async handler (../node_modules/@convex-dev/resend/src/component/lib.ts:633:16)

✓ Compiled in 36ms
8/2/2025, 6:32:48 PM [CONVEX H(POST /resend-webhook)] Uncaught Error: Uncaught Error: unique() query returned more than one result from table emails:
[j57chbeghegwtj8yedvha2xgzn7mxp76, j57f4d10sj6g3kjzn9kk5sb88d7mw76d, ...]
at async handler (../node_modules/@convex-dev/resend/src/component/lib.ts:633:16)

at async handleResendEventWebhook [as handleResendEventWebhook] (../../node_modules/@convex-dev/resend/src/client/index.ts:360:6)
at async <anonymous> (../convex/http.ts:12:1)
at async invokeFunction (../../node_modules/convex/src/server/impl/registration_impl.ts:80:2)
at async invokeHttpAction (../../node_modules/convex/src/server/impl/registration_impl.ts:453:0)
at async <anonymous> (../../node_modules/convex/src/server/router.ts:322:16)

Dont know how can i share my db data but here is instance name "proficient-stork-122"

Root Cause : So if the mail fails the resend ID is unset then the unique query is failing

So the issue is that handleEmailEvent takes in an event typed to any but then trusts that it is a valid EmailEvent. 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 handleEmailEvent so 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.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Aug 5, 2025

Open in StackBlitz

npm i https://pkg.pr.new/get-convex/resend/@convex-dev/resend@30

commit: 490f6e8

Copy link

Copilot AI left a 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 handleEmailEvent function 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
Copy link

Copilot AI Aug 5, 2025

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.

Suggested change
// 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.

Copilot uses AI. Check for mistakes.
opened: false,
resendId: "test-resend-id-123",
segment: 1,
finalizedAt: Number.MAX_SAFE_INTEGER, // FINALIZED_EPOCH
Copy link

Copilot AI Aug 5, 2025

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.

Suggested change
finalizedAt: Number.MAX_SAFE_INTEGER, // FINALIZED_EPOCH
finalizedAt: FINALIZED_EPOCH,

Copilot uses AI. Check for mistakes.
@get-convex get-convex deleted a comment from Copilot AI Aug 5, 2025
type RunQueryCtx,
} from "../component/shared.js";

export type UsedAPI = UseApi<typeof api>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export type UsedAPI = UseApi<typeof api>;
export type ResendComponent = UseApi<typeof api>;

Copy link
Contributor

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}.`
Copy link
Contributor

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..

Comment on lines 644 to 647
if (!email)
return console.info(
`Email not found for resendId: ${event.data.email_id}, ignoring...`
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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

Copy link
Contributor Author

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();
Copy link
Contributor

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

Copy link
Contributor Author

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")
Copy link
Contributor

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?

Copy link
Contributor Author

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);
Copy link
Contributor

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?

Copy link
Contributor Author

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?

Copy link
Contributor

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.

@ianmacartney
Copy link
Contributor

ianmacartney commented Aug 5, 2025 via email

@mikecann
Copy link
Contributor Author

mikecann commented Aug 6, 2025

Ye @ianmacartney I think you are referencing a comment that I subsequently deleted as I realised that indeed that was the case.

@ianmacartney ianmacartney merged commit 81a5702 into main Aug 6, 2025
2 checks passed
@ianmacartney
Copy link
Contributor

ianmacartney commented Aug 6, 2025

@convex-dev/[email protected]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants