Add Suppressions API support (add + list + get + remove + batch) - #134
Conversation
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.
There was a problem hiding this comment.
All reported issues were addressed across 13 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…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.
There was a problem hiding this comment.
All reported issues were addressed across 6 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
`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.
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Confidence score: 4/5
- In
tools/Resend.ApiServer/Controllers/SuppressionController.cs,ValidateEmailsonly checks for null/whitespace, so batch add/remove can accept malformed addresses likenot-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 ) |
There was a problem hiding this comment.
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>.
There was a problem hiding this comment.
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
What
Adds suppression-list support to
IResend/ResendClient:SuppressionAddAsync,SuppressionListAsync,SuppressionRetrieveAsync,SuppressionRemoveAsync,SuppressionBatchAddAsync,SuppressionBatchRemoveAsync— all returningResendResponse<T>and taking aCancellationToken.Follows the Domain Claims PR (#133) for structure: partial class
ResendClient.Suppressions.cs, models insrc/Resend, request objects underPayloads, and the existingJsonStringEnumValueConverter+[JsonStringValue]pattern forSuppressionOrigin(asDomainClaimStatusdoes). Reuses the existingPaginatedResult<T>forhas_more/data.Details worth calling out:
SuppressionRetrieveAsync/SuppressionRemoveAsynctake astring, not aGuid, because the path parameter accepts an ID or an email address (the server doesvalidator.isUUID()and treats a non-UUID as an email). Escaped withUri.EscapeDataString.IEnumerable<string> emailsandIEnumerable<Guid> suppressionIds— which makes the emails-XOR-ids constraint a compile-time guarantee rather than a runtime check. Both payload properties useJsonIgnoreCondition.WhenWritingNullso the unset one is omitted.SuppressionSummary(list entries) andSuppression(get) are separate types, differing only byObject.source_idis typedstring?, notGuid?: 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.IdstaysGuid, 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), thenresend-openapiresend.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
objectfield; only the single-getresponse does.list.tsselects exactly{id, email, origin, source_id, created_at}and adds nothing, whileget.tsreturns{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:
list-suppressionsdocs example shows a per-entryobjectthat the API never sendsresend-nodeSuppressionListEntrydeclares the same nonexistent field, and is shipped inresend@6.18.0The 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/removeacceptsemailsXORids; the unset key must be omitted, since the server validation accepts undefined but rejects an explicitnullbatch/addupserts — so batch responses can contain fewer entries than were sent; callers must not pair results to inputs by indexbatch/removereturns only rows actually deleted (misses are absent, no error), whereas singleremovereturns 404 on a misssource_idis nullable and isnullformanual-origin suppressionsRelease 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
Run on the .NET 8 SDK to match CI (
dotnet-version: 8.0.x) and thenet8.0target. Worth knowing for anyone reproducing locally: on a machine with only the .NET 10 runtime installed,dotnet testaborts outright, and forcingDOTNET_ROLL_FORWARD=Majoryields ~61 spurious failures out of 131 on unmodifiedmain— so a .NET 8 runtime is required for a meaningful run.Tests cover all six operations plus null
source_id, a non-UUIDsource_idon both the single and list shapes, and the real Postgres-stylecreated_atformat 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
SuppressionAddAsync,SuppressionListAsync,SuppressionRetrieveAsync,SuppressionRemoveAsync,SuppressionBatchAddAsync,SuppressionBatchRemoveAsync.SuppressionRetrieveAsync/SuppressionRemoveAsyncaccept an ID or an email (string). Input is URL-encoded; blank values throwArgumentException.Suppression(hasobject) andSuppressionSummary(noobject).source_idisstring?to allow non-UUID values;IdremainsGuid.originfilter. Endpoints may return 404 for teams without the feature flag.Bug Fixes
JsonStringEnumValue<T>and use it for theoriginquery.Written for commit c8bb622. Summary will update on new commits.