Skip to content

Add Suppressions API support (add + list + get + remove + batch) - #134

Merged
felipefreitag merged 4 commits into
mainfrom
add-suppressions-api
Jul 27, 2026
Merged

Add Suppressions API support (add + list + get + remove + batch)#134
felipefreitag merged 4 commits into
mainfrom
add-suppressions-api

Conversation

@felipefreitag

@felipefreitag felipefreitag commented Jul 27, 2026

Copy link
Copy Markdown
Member

What

Adds suppression-list support to IResend / ResendClient: SuppressionAddAsync, SuppressionListAsync, SuppressionRetrieveAsync, SuppressionRemoveAsync, SuppressionBatchAddAsync, SuppressionBatchRemoveAsync — all returning ResendResponse<T> and taking a CancellationToken.

Follows the Domain Claims PR (#133) for structure: partial class ResendClient.Suppressions.cs, models in src/Resend, request objects under Payloads, and the existing JsonStringEnumValueConverter + [JsonStringValue] pattern for SuppressionOrigin (as DomainClaimStatus does). Reuses the existing PaginatedResult<T> for has_more/data.

Details worth calling out:

  • SuppressionRetrieveAsync/SuppressionRemoveAsync take a string, not a Guid, because the path parameter accepts an ID or an email address (the server does validator.isUUID() and treats a non-UUID as an email). Escaped with Uri.EscapeDataString.
  • Batch remove is expressed as two overloadsIEnumerable<string> emails and IEnumerable<Guid> suppressionIds — which makes the emails-XOR-ids constraint a compile-time guarantee rather than a runtime check. Both payload properties use JsonIgnoreCondition.WhenWritingNull so the unset one is omitted.
  • SuppressionSummary (list entries) and Suppression (get) are separate types, differing only by Object.
  • source_id is typed string?, not Guid?: the underlying column is free-text and the spec declares a plain string, so a non-UUID value would otherwise throw and discard an entire list page. Id stays Guid, which the schema does back.

Contract verification

Verified in order of authority: the API server implementation (apps/public-api/src/routes/suppressions/ in the monorepo), then resend-openapi resend.yaml, then the docs — and finally end-to-end against the live API with a real team key (read-only operations).

One deliberate divergence, flagged so it does not look like a bug in review: list entries do not carry the object field; only the single-get response does. list.ts selects exactly {id, email, origin, source_id, created_at} and adds nothing, while get.ts returns {object: "suppression", ...}. Confirmed on live wire output.

Two upstream artifacts disagree with the server here and are being tracked separately, so please do not use them as the reference when reviewing:

  • the list-suppressions docs example shows a per-entry object that the API never sends
  • resend-node SuppressionListEntry declares the same nonexistent field, and is shipped in resend@6.18.0

The OpenAPI spec is correct on this point and agrees with the implementation.

Other behaviour confirmed at the server and reflected in docs/tests, not reimplemented client-side:

  • batch/remove accepts emails XOR ids; the unset key must be omitted, since the server validation accepts undefined but rejects an explicit null
  • the API lowercases, trims and deduplicates emails, and batch/add upserts — so batch responses can contain fewer entries than were sent; callers must not pair results to inputs by index
  • batch/remove returns only rows actually deleted (misses are absent, no error), whereas single remove returns 404 on a miss
  • source_id is nullable and is null for manual-origin suppressions

Release timing

These endpoints are in beta and gated behind a server-side feature flag: a team without access receives 404 on every suppression endpoint. Merging is safe, but worth a deliberate decision on whether this ships in the next release or waits for wider flag rollout, since otherwise a documented SDK method returns 404 for most users.

Verification

dotnet build   -> 0 Errors
dotnet test    -> 173 passed (158 Resend.Tests + 11 Webhooks + 4 FluentEmail), 0 failed

Run on the .NET 8 SDK to match CI (dotnet-version: 8.0.x) and the net8.0 target. Worth knowing for anyone reproducing locally: on a machine with only the .NET 10 runtime installed, dotnet test aborts outright, and forcing DOTNET_ROLL_FORWARD=Major yields ~61 spurious failures out of 131 on unmodified main — so a .NET 8 runtime is required for a meaningful run.

Tests cover all six operations plus null source_id, a non-UUID source_id on both the single and list shapes, and the real Postgres-style created_at format the API actually emits (2026-07-24 18:30:00.123456+00) rather than only the ISO form shown in the docs.


Summary by cubic

Adds Suppressions API support to the .NET client for add, list, get, remove, and batch operations to manage suppressed emails. Matches server behavior and edge cases.

  • New Features

    • Methods: SuppressionAddAsync, SuppressionListAsync, SuppressionRetrieveAsync, SuppressionRemoveAsync, SuppressionBatchAddAsync, SuppressionBatchRemoveAsync.
    • SuppressionRetrieveAsync/SuppressionRemoveAsync accept an ID or an email (string). Input is URL-encoded; blank values throw ArgumentException.
    • Separate models for single vs list: Suppression (has object) and SuppressionSummary (no object).
    • source_id is string? to allow non-UUID values; Id remains Guid.
    • Batch remove has two overloads (emails or ids). The unset field is omitted in the payload to satisfy the XOR rule.
    • Batch responses can be shorter than inputs (email dedupe; remove returns only deleted). Do not pair results to inputs by index.
    • List supports pagination and optional origin filter. Endpoints may return 404 for teams without the feature flag.
  • Bug Fixes

    • Mock server: reject null/blank entries in suppression batches with 422 (not 500), matching API validation and preventing silent drops.
    • Enum JSON handling: allow aliases and throw on conflicting wire values; share wire mapping via JsonStringEnumValue<T> and use it for the origin query.

Written for commit c8bb622. Summary will update on new commits.

Review in cubic

Mirrors the resend-node surface: single add/list/get/remove plus batch
add/remove. The `{suppression}` path parameter accepts an ID or an email
address, so it is typed as string and URL-encoded.

List entries and the single-suppression response are separate types: the
list endpoint selects only id/email/origin/source_id/created_at, while
get adds the `object` discriminator. Follows the Automation/AutomationSummary
split rather than advertising a property the list never sends.

`source_id` is a string, not a Guid: the column is free-text, so a non-UUID
value would otherwise fail deserialization of an entire list page.

Batch remove takes either emails or ids, never both, and the server's zod
schema accepts undefined rather than null -- modelled as two overloads over
a payload that omits the unset property.

Both batch responses can be shorter than the request -- the API dedupes
addresses, and batch remove returns only rows actually deleted -- so the
docs warn against pairing the result with the input by index.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 13 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/Resend/ResendClient.Suppressions.cs Outdated
Comment thread tools/Resend.ApiServer/Controllers/SuppressionController.cs
…m attribute

`SuppressionRetrieveAsync("")` requested `/suppressions/`, which the API
routes to the collection (Express strict routing is off), and the list
payload deserializes into an all-default `Suppression` -- no `required`
members, unknown properties ignored -- so callers got a silently wrong
object instead of an error. Both single-suppression methods now reject a
null, empty or whitespace identifier, matching the Python, Go and Ruby
SDKs. Remove happened to 404 today, but only because the collection has
no DELETE route.

The origin query value restated the mapping already declared by
`JsonStringValue`, and defaulted to throwing `NotImplementedException`,
so a new origin had to be added in two places. The attribute is now the
single source of truth: the per-enum map behind the JSON converter moved
into `JsonStringEnumValue<T>`, which the query string builder reuses.

Also drops `[JsonIgnore]` from `SuppressionListQuery.Origin` -- the query
is hand-serialized into a query string, and the inherited pagination
properties carry no JSON attributes either.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 6 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/Resend/Json/JsonStringEnumValue.cs Outdated
`Normalize` dereferenced every entry, so `{"emails": ["a@example.com", null]}`
threw a NullReferenceException out of both batch endpoints and the mock server
answered 500. A whitespace-only entry was worse: it trimmed to the empty string
and was accepted with a 200.

The API validates each entry with `z.string().email()` and answers 422, so the
mock now does the same rather than dropping the entry -- silently shortening the
batch would model behaviour the API does not have, and 500 is not a response the
SDK should ever see for a malformed body. `EmailController` already answers an
invalid body with an `ErrorResponse`, and this controller already rejects the
mutually-exclusive `emails`/`ids` combination, so failing the request is the
convention here.

A missing `emails` array crashed the add endpoint the same way and is rejected
along with it.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 2 files (changes from recent commits).

Confidence score: 4/5

  • In tools/Resend.ApiServer/Controllers/SuppressionController.cs, ValidateEmails only checks for null/whitespace, so batch add/remove can accept malformed addresses like not-an-email, which can pollute suppression data and create inconsistent behavior versus the API’s single-email validation. Reuse the same email-format validator for batch inputs (and add a malformed-email test case) to de-risk this path.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="tools/Resend.ApiServer/Controllers/SuppressionController.cs">

<violation number="1" location="tools/Resend.ApiServer/Controllers/SuppressionController.cs:152">
P2: Batch add/remove accept malformed email strings such as `not-an-email`; `ValidateEmails` checks only null/whitespace and never validates email syntax. Using the same email-format validation as the API, with a malformed-address test, would keep the mock behavior aligned with its stated contract.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

/// </summary>
private ActionResult? ValidateEmails( IEnumerable<string>? emails )
{
if ( emails != null && emails.Any( x => string.IsNullOrWhiteSpace( x ) ) == false )

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Batch add/remove accept malformed email strings such as not-an-email; ValidateEmails checks only null/whitespace and never validates email syntax. Using the same email-format validation as the API, with a malformed-address test, would keep the mock behavior aligned with its stated contract.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tools/Resend.ApiServer/Controllers/SuppressionController.cs, line 152:

<comment>Batch add/remove accept malformed email strings such as `not-an-email`; `ValidateEmails` checks only null/whitespace and never validates email syntax. Using the same email-format validation as the API, with a malformed-address test, would keep the mock behavior aligned with its stated contract.</comment>

<file context>
@@ -129,6 +143,24 @@ public ActionResult<ListOf<SuppressionRemoveResult>> SuppressionBatchRemove( [Fr
+    /// </summary>
+    private ActionResult? ValidateEmails( IEnumerable<string>? emails )
+    {
+        if ( emails != null && emails.Any( x => string.IsNullOrWhiteSpace( x ) ) == false )
+            return null;
+
</file context>

Building the wire-value maps used Dictionary.Add for both directions, so an
enum declaring an alias -- several names sharing one underlying value, which
is legal C# -- threw on the second name and made the converter unusable for
that type. The first name encountered now supplies the wire value instead.

A repeated wire value is still rejected, but only when it denotes a different
enum value, which is genuinely ambiguous to deserialize. Two names sharing a
value and a wire string are harmless.

Pre-existing: the same Add pair lived in the converter constructor before the
maps moved into JsonStringEnumValue<T>.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

0 issues found across 2 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Requires human review: Auto-approval blocked by 1 unresolved issue from previous reviews.

Re-trigger cubic

@felipefreitag
felipefreitag merged commit 4575f06 into main Jul 27, 2026
5 checks passed
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.

2 participants