Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/Resend/BroadcastListRecipientsQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace Resend;

/// <summary>
/// Query parameters for <see cref="IResend.BroadcastListRecipientsAsync"/>.
/// </summary>
public class BroadcastListRecipientsQuery : PaginatedQuery
{
/// <summary>
/// Filters recipients whose email contains this value.
/// </summary>
public string? Email { get; set; }

/// <summary>
/// Filters bounced recipients by bounce classification.
/// </summary>
/// <remarks>
/// Only meaningful when the requested event type is <see cref="BroadcastRecipientEventType.Bounced"/>.
/// </remarks>
public BroadcastRecipientBounceType? BounceType { get; set; }
}
68 changes: 68 additions & 0 deletions src/Resend/BroadcastRecipient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System.Text.Json.Serialization;

namespace Resend;

/// <summary>
/// A broadcast recipient, as returned for a given <see cref="BroadcastRecipientEventType"/>.
/// </summary>
/// <see href="https://resend.com/docs/api-reference/broadcasts/list-broadcast-recipients" />
public class BroadcastRecipient
{
/// <summary>
/// Opaque cursor identifying this row, used only for pagination.
/// </summary>
/// <remarks>
/// Does not identify any entity in Resend -- use <see cref="ContactId"/> to reference
/// the contact.
/// </remarks>
[JsonPropertyName( "id" )]
public string Id { get; set; } = default!;

/// <summary>
/// Identifier of the contact matching this recipient.
/// </summary>
/// <remarks>
/// Null when the recipient's email no longer maps to a contact.
/// </remarks>
[JsonPropertyName( "contact_id" )]
public Guid? ContactId { get; set; }

/// <summary>
/// The recipient's email address.
/// </summary>
[JsonPropertyName( "email" )]
public string Email { get; set; } = default!;

/// <summary>
/// Number of times this recipient triggered the event.
/// </summary>
/// <remarks>
/// Only present when the requested <see cref="BroadcastRecipientEventType"/> is
/// <see cref="BroadcastRecipientEventType.Opened"/> or <see cref="BroadcastRecipientEventType.Clicked"/>.
/// </remarks>
[JsonPropertyName( "count" )]
[JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )]
public int? Count { get; set; }

/// <summary>
/// The bounce classification.
/// </summary>
/// <remarks>
/// Only present when the requested <see cref="BroadcastRecipientEventType"/> is
/// <see cref="BroadcastRecipientEventType.Bounced"/>.
/// </remarks>
[JsonPropertyName( "bounce_type" )]
[JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )]
public BroadcastRecipientBounceType? BounceType { get; set; }

/// <summary>
/// The links this recipient clicked.
/// </summary>
/// <remarks>
/// Only present when the requested <see cref="BroadcastRecipientEventType"/> is
/// <see cref="BroadcastRecipientEventType.Clicked"/>.
/// </remarks>
[JsonPropertyName( "clicked_links" )]
[JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )]
public List<BroadcastRecipientClickedLink>? ClickedLinks { get; set; }
}
28 changes: 28 additions & 0 deletions src/Resend/BroadcastRecipientBounceType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Text.Json.Serialization;

namespace Resend;

/// <summary>
/// Broadcast recipient bounce classifications.
/// </summary>
[JsonConverter( typeof( JsonStringEnumValueConverter<BroadcastRecipientBounceType> ) )]
public enum BroadcastRecipientBounceType
{
/// <summary>
/// The bounce is permanent; the address is not expected to become deliverable.
/// </summary>
[JsonStringValue( "permanent" )]
Permanent,

/// <summary>
/// The bounce is transient; the address may become deliverable again.
/// </summary>
[JsonStringValue( "transient" )]
Transient,

/// <summary>
/// The bounce could not be classified.
/// </summary>
[JsonStringValue( "undetermined" )]
Undetermined,
}
21 changes: 21 additions & 0 deletions src/Resend/BroadcastRecipientClickedLink.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;

namespace Resend;

/// <summary>
/// A link clicked by a broadcast recipient.
/// </summary>
public class BroadcastRecipientClickedLink
{
/// <summary>
/// The clicked URL.
/// </summary>
[JsonPropertyName( "url" )]
public string Url { get; set; } = default!;

/// <summary>
/// Number of times this recipient clicked this URL.
/// </summary>
[JsonPropertyName( "clicks" )]
public int Clicks { get; set; }
}
58 changes: 58 additions & 0 deletions src/Resend/BroadcastRecipientEventType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System.Text.Json.Serialization;

namespace Resend;

/// <summary>
/// Broadcast recipient event types.
/// </summary>
[JsonConverter( typeof( JsonStringEnumValueConverter<BroadcastRecipientEventType> ) )]
public enum BroadcastRecipientEventType
{
/// <summary>
/// Recipient the broadcast was sent to.
/// </summary>
[JsonStringValue( "sent" )]
Sent,

/// <summary>
/// Recipient whose email was delivered.
/// </summary>
[JsonStringValue( "delivered" )]
Delivered,

/// <summary>
/// Recipient who opened the email.
/// </summary>
[JsonStringValue( "opened" )]
Opened,

/// <summary>
/// Recipient who clicked a link in the email.
/// </summary>
[JsonStringValue( "clicked" )]
Clicked,

/// <summary>
/// Recipient whose email bounced.
/// </summary>
[JsonStringValue( "bounced" )]
Bounced,

/// <summary>
/// Recipient who marked the email as spam.
/// </summary>
[JsonStringValue( "complained" )]
Complained,

/// <summary>
/// Recipient who unsubscribed.
/// </summary>
[JsonStringValue( "unsubscribed" )]
Unsubscribed,

/// <summary>
/// Recipient who was suppressed and did not receive the email.
/// </summary>
[JsonStringValue( "suppressed" )]
Suppressed,
}
11 changes: 11 additions & 0 deletions src/Resend/IResend.cs
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,17 @@ public interface IResend
/// <returns>List of broadcasts</returns>
Task<ResendResponse<List<Broadcast>>> BroadcastListAsync( CancellationToken cancellationToken = default );

/// <summary>
/// Lists a broadcast's recipients for a given event type.
/// </summary>
/// <param name="broadcastId">Broadcast identifier.</param>
/// <param name="type">Recipient event type to filter by.</param>
/// <param name="query">Filters and pagination.</param>
/// <param name="cancellationToken">Cancelation token.</param>
/// <returns>Page of recipients.</returns>
/// <see href="https://resend.com/docs/api-reference/broadcasts/list-broadcast-recipients"/>
Task<ResendResponse<PaginatedResult<BroadcastRecipient>>> BroadcastListRecipientsAsync( Guid broadcastId, BroadcastRecipientEventType type, BroadcastListRecipientsQuery? query = null, CancellationToken cancellationToken = default );

/// <summary>
/// Removes a broadcast.
/// </summary>
Expand Down
41 changes: 41 additions & 0 deletions src/Resend/ResendClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,47 @@ public ResendClient( IOptionsSnapshot<ResendClientOptions> options, HttpClient h
}


/// <inheritdoc/>
public Task<ResendResponse<PaginatedResult<BroadcastRecipient>>> BroadcastListRecipientsAsync( Guid broadcastId, BroadcastRecipientEventType type, BroadcastListRecipientsQuery? query = null, CancellationToken cancellationToken = default )
{
var baseUrl = $"/broadcasts/{broadcastId}/recipients";

var qs = new Dictionary<string, string?>()
{
{ "type", JsonStringEnumValue<BroadcastRecipientEventType>.Of( type ) },
};

if ( query != null )
{
if ( query.Limit.HasValue == true )
qs.Add( "limit", query.Limit.Value.ToString() );

if ( query.Before != null )
qs.Add( "before", query.Before );

if ( query.After != null )
qs.Add( "after", query.After );

if ( query.Email != null )
qs.Add( "email", query.Email );

if ( query.BounceType.HasValue == true )
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
{
if ( type != BroadcastRecipientEventType.Bounced )
throw new ArgumentException( "BounceType can only be set when type is Bounced.", nameof( query ) );

qs.Add( "bounce_type", JsonStringEnumValue<BroadcastRecipientBounceType>.Of( query.BounceType.Value ) );
}
}

var url = QueryHelpers.AddQueryString( baseUrl, qs );

var req = new HttpRequestMessage( HttpMethod.Get, url );

return Execute<PaginatedResult<BroadcastRecipient>, PaginatedResult<BroadcastRecipient>>( req, ( x ) => x, cancellationToken );
}


/// <inheritdoc/>
public Task<ResendResponse> BroadcastDeleteAsync( Guid broadcastId, CancellationToken cancellationToken = default )
{
Expand Down
115 changes: 115 additions & 0 deletions tests/Resend.Tests/ResendClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,121 @@ public async Task BroadcastList()
}


/// <summary/>
[Fact]
public async Task BroadcastListRecipients()
{
var resp = await _resend.BroadcastListRecipientsAsync( Guid.NewGuid(), BroadcastRecipientEventType.Delivered );

Assert.NotNull( resp );
Assert.True( resp.Success );
Assert.NotNull( resp.Content );
Assert.True( resp.Content.HasMore );
Assert.Single( resp.Content.Data );

var recipient = resp.Content.Data[ 0 ];
Assert.NotEqual( "", recipient.Id );
Assert.NotNull( recipient.ContactId );
Assert.NotEqual( "", recipient.Email );
Assert.Null( recipient.Count );
Assert.Null( recipient.BounceType );
Assert.Null( recipient.ClickedLinks );
}


/// <summary/>
[Fact]
public async Task BroadcastListRecipientsOpened()
{
var resp = await _resend.BroadcastListRecipientsAsync( Guid.NewGuid(), BroadcastRecipientEventType.Opened );

Assert.NotNull( resp );
Assert.NotNull( resp.Content );

var recipient = resp.Content.Data[ 0 ];
Assert.Equal( 3, recipient.Count );
Assert.Null( recipient.BounceType );
Assert.Null( recipient.ClickedLinks );
}


/// <summary/>
[Fact]
public async Task BroadcastListRecipientsClicked()
{
var resp = await _resend.BroadcastListRecipientsAsync( Guid.NewGuid(), BroadcastRecipientEventType.Clicked );

Assert.NotNull( resp );
Assert.NotNull( resp.Content );

var recipient = resp.Content.Data[ 0 ];
Assert.Equal( 3, recipient.Count );
Assert.NotNull( recipient.ClickedLinks );
Assert.Single( recipient.ClickedLinks );
Assert.Equal( "https://resend.com/pricing", recipient.ClickedLinks[ 0 ].Url );
Assert.Equal( 2, recipient.ClickedLinks[ 0 ].Clicks );
}


/// <summary/>
[Fact]
public async Task BroadcastListRecipientsBounced()
{
var resp = await _resend.BroadcastListRecipientsAsync(
Guid.NewGuid(),
BroadcastRecipientEventType.Bounced,
new BroadcastListRecipientsQuery() { BounceType = BroadcastRecipientBounceType.Transient } );

Assert.NotNull( resp );
Assert.NotNull( resp.Content );

var recipient = resp.Content.Data[ 0 ];
Assert.Equal( BroadcastRecipientBounceType.Transient, recipient.BounceType );
}


/// <summary/>
[Fact]
public async Task BroadcastListRecipientsWithQuery()
{
var resp = await _resend.BroadcastListRecipientsAsync(
Guid.NewGuid(),
BroadcastRecipientEventType.Delivered,
new BroadcastListRecipientsQuery()
Comment thread
dielduarte marked this conversation as resolved.
{
Email = "steve.wozniak@gmail.com",
Limit = 10,
After = Guid.NewGuid().ToString(),
} );

Assert.NotNull( resp );
Assert.NotNull( resp.Content );
Assert.Equal( "steve.wozniak@gmail.com", resp.Content.Data[ 0 ].Email );
}


/// <summary/>
[Fact]
public async Task BroadcastListRecipientsBounceTypeRequiresBouncedType()
{
await Assert.ThrowsAsync<ArgumentException>( () => _resend.BroadcastListRecipientsAsync(
Guid.NewGuid(),
BroadcastRecipientEventType.Delivered,
new BroadcastListRecipientsQuery() { BounceType = BroadcastRecipientBounceType.Permanent } ) );
}


/// <summary/>
[Fact]
public async Task BroadcastListRecipientsNotFound()
{
var ex = await Assert.ThrowsAsync<ResendException>( () => _resend.BroadcastListRecipientsAsync( Guid.Empty, BroadcastRecipientEventType.Delivered ) );

Assert.Equal( HttpStatusCode.NotFound, ex.StatusCode );
Assert.Equal( ErrorType.NotFound, ex.ErrorType );
}


/// <summary/>
[Fact]
public async Task BroadcastDelete()
Expand Down
Loading
Loading