get-convex / get-convex/resend

email.suppressed webhook events are dropped (not in ACCEPTED_EVENT_TYPES / vEmailEvent), leaving email state stale

Open
#113 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
39
Forks
25
Avg merge
2d 13h
Merged PRs (30d)
2

Description

### Summary

Resend's documented [`email.suppressed` webhook event](https://resend.com/docs/dashboard/webhooks/event-types) is rejected by the component's webhook handler, so applications never learn that a send was blocked by Resend's suppression list. The email's status is never updated and the app-level `onEmailEvent` callback is never invoked for it.

### Where it happens

`src/component/shared.ts` — `email.suppressed` is missing from both the event validator union and the accepted list:

```ts
export const ACCEPTED_EVENT_TYPES = [
"email.sent",
"email.delivered",
"email.bounced",
"email.complained",
"email.failed",
"email.delivery_delayed",
"email.opened",
"email.clicked",
] as const;
```

`vEmailEvent` (same file) is a union of the same types, so in `handleEmailEvent` (`src/component/lib.ts`) the parse fails and the event is dropped with only a warning:

```ts
const result = attemptToParse(vEmailEvent, args.event);
if (result.kind === "error") {
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}.`,
);
return;
}
```

The warning even suggests excluding the event in the Resend dashboard — but `email.suppressed` is a legitimate terminal outcome for an email, not noise.

### Impact

When Resend suppresses a send (recipient on the account-level suppression list), the email's component status stays at its last known state and downstream apps that track message state via `onEmailEvent` show the message stuck (e.g. permanently "queued"/"sent"), with no signal that delivery will never happen. There's also no way to feed suppression back into an app's own consent/suppression handling.

### Suggested fix

- Add `email.suppressed` to `ACCEPTED_EVENT_TYPES` and to the `vEmailEvent` union (payload appears to carry the common fields; docs page: https://resend.com/docs/webhooks/emails/suppressed).
- Treat it as a terminal, non-retryable state in `computeEmailUpdateFromEvent` (similar to `email.failed`/`email.bounced`), so `finalizedAt`-style semantics and the `onEmailEvent` callback both fire.

Happy to open a PR if the maintainers agree on the desired status semantics.

**Observed with:** `@convex-dev/resend` 0.2.6 (latest); `main` at time of filing has the same `ACCEPTED_EVENT_TYPES`.

Contributor guide

Open the contributing guide

Research direction

Start in src/component/shared.ts by comparing ACCEPTED_EVENT_TYPES and vEmailEvent with Resend's email.suppressed payload, then follow handleEmailEvent in src/component/lib.ts into computeEmailUpdateFromEvent. Done means the event is accepted, treated as a terminal non-retryable outcome like failed or bounced, updates email state, and invokes onEmailEvent.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, backend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.