From a7f8ed7dab916a266e982165058ec6befc4319d2 Mon Sep 17 00:00:00 2001 From: dielduarte Date: Fri, 21 Aug 2026 19:35:37 -0300 Subject: [PATCH 1/3] feat: add BroadcastClickedLinksAsync() method --- src/Resend/BroadcastClickedLink.cs | 34 ++++++++++++++++++ src/Resend/IResend.cs | 9 +++++ src/Resend/ResendClient.cs | 28 +++++++++++++++ tests/Resend.Tests/ResendClientTests.cs | 35 ++++++++++++++++++ .../Controllers/BroadcastController.cs | 36 +++++++++++++++++++ 5 files changed, 142 insertions(+) create mode 100644 src/Resend/BroadcastClickedLink.cs diff --git a/src/Resend/BroadcastClickedLink.cs b/src/Resend/BroadcastClickedLink.cs new file mode 100644 index 0000000..a3c880f --- /dev/null +++ b/src/Resend/BroadcastClickedLink.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace Resend; + +/// +/// A single clicked link in a broadcast clicked-links list response. +/// +public class BroadcastClickedLink +{ + /// + /// An opaque cursor for this row, used only for pagination. + /// It does not identify any entity in Resend. + /// + [JsonPropertyName( "id" )] + public string Id { get; set; } = default!; + + /// + /// The URL that was clicked. + /// + [JsonPropertyName( "url" )] + public string Url { get; set; } = default!; + + /// + /// Total number of clicks on this URL. + /// + [JsonPropertyName( "clicks" )] + public int Clicks { get; set; } + + /// + /// Number of unique clicks on this URL. + /// + [JsonPropertyName( "unique_clicks" )] + public int UniqueClicks { get; set; } +} diff --git a/src/Resend/IResend.cs b/src/Resend/IResend.cs index 1d6523c..2492b14 100644 --- a/src/Resend/IResend.cs +++ b/src/Resend/IResend.cs @@ -853,6 +853,15 @@ public interface IResend /// Response. Task BroadcastDeleteAsync( Guid broadcastId, CancellationToken cancellationToken = default ); + /// + /// Lists the links clicked in a broadcast. + /// + /// Broadcast identifier. + /// Pagination query. + /// Cancelation token. + /// Page of clicked links. + Task>> BroadcastClickedLinksAsync( Guid broadcastId, PaginatedQuery? query = null, CancellationToken cancellationToken = default ); + #endregion #region Receiving diff --git a/src/Resend/ResendClient.cs b/src/Resend/ResendClient.cs index 9273723..9fd4a9d 100644 --- a/src/Resend/ResendClient.cs +++ b/src/Resend/ResendClient.cs @@ -489,6 +489,34 @@ public ResendClient( IOptionsSnapshot options, HttpClient h } + /// + public Task>> BroadcastClickedLinksAsync( Guid broadcastId, PaginatedQuery? query = null, CancellationToken cancellationToken = default ) + { + var baseUrl = $"/broadcasts/{broadcastId}/clicked-links"; + var url = baseUrl; + + if ( query != null ) + { + var qs = new Dictionary(); + + 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 ); + + url = QueryHelpers.AddQueryString( baseUrl, qs ); + } + + var req = new HttpRequestMessage( HttpMethod.Get, url ); + + return Execute, PaginatedResult>( req, ( x ) => x, cancellationToken ); + } + + /// private async Task Execute( HttpRequestMessage req, CancellationToken cancellationToken ) { diff --git a/tests/Resend.Tests/ResendClientTests.cs b/tests/Resend.Tests/ResendClientTests.cs index 4f2010d..12f69cb 100644 --- a/tests/Resend.Tests/ResendClientTests.cs +++ b/tests/Resend.Tests/ResendClientTests.cs @@ -499,4 +499,39 @@ public async Task BroadcastDelete() Assert.NotNull( resp ); } + + + /// + [Fact] + public async Task BroadcastClickedLinks() + { + var broadcastId = Guid.NewGuid(); + + var resp = await _resend.BroadcastClickedLinksAsync( broadcastId ); + + Assert.NotNull( resp ); + Assert.True( resp.Success ); + Assert.False( resp.Content.HasMore ); + Assert.Equal( 2, resp.Content.Data.Count ); + Assert.Equal( "https://resend.com/pricing", resp.Content.Data[ 0 ].Url ); + Assert.Equal( 42, resp.Content.Data[ 0 ].Clicks ); + Assert.Equal( 30, resp.Content.Data[ 0 ].UniqueClicks ); + } + + + /// + [Fact] + public async Task BroadcastClickedLinks_WithQuery() + { + var broadcastId = Guid.NewGuid(); + + var resp = await _resend.BroadcastClickedLinksAsync( broadcastId, new PaginatedQuery() + { + Limit = 10, + After = "cursor-value", + } ); + + Assert.NotNull( resp ); + Assert.True( resp.Success ); + } } diff --git a/tools/Resend.ApiServer/Controllers/BroadcastController.cs b/tools/Resend.ApiServer/Controllers/BroadcastController.cs index 9bf1feb..47a45c3 100644 --- a/tools/Resend.ApiServer/Controllers/BroadcastController.cs +++ b/tools/Resend.ApiServer/Controllers/BroadcastController.cs @@ -144,4 +144,40 @@ public ListOf BroadcastList() Data = list, }; } + + + /// + [HttpGet] + [Route( "broadcasts/{broadcastId}/clicked-links" )] + public PaginatedResult BroadcastClickedLinks( + [FromRoute] Guid broadcastId, + [FromQuery] string? limit = null, + [FromQuery] string? before = null, + [FromQuery] string? after = null + ) + { + _logger.LogDebug( "BroadcastClickedLinks" ); + + return new PaginatedResult() + { + HasMore = false, + Data = + [ + new BroadcastClickedLink() + { + Id = "b2Zmc2V0OjA", + Url = "https://resend.com/pricing", + Clicks = 42, + UniqueClicks = 30, + }, + new BroadcastClickedLink() + { + Id = "b2Zmc2V0OjE", + Url = "https://resend.com/docs", + Clicks = 17, + UniqueClicks = 15, + }, + ], + }; + } } From ce86f69cde568ee3f66356ee8f451d2422e88151 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Mon, 24 Aug 2026 11:01:08 -0300 Subject: [PATCH 2/3] feat: add broadcast clicked-links CLI command --- .../Broadcast/BroadcastClickedLinksCommand.cs | 66 +++++++++++++++++++ tools/Resend.Cli/BroadcastCommand.cs | 1 + 2 files changed, 67 insertions(+) create mode 100644 tools/Resend.Cli/Broadcast/BroadcastClickedLinksCommand.cs diff --git a/tools/Resend.Cli/Broadcast/BroadcastClickedLinksCommand.cs b/tools/Resend.Cli/Broadcast/BroadcastClickedLinksCommand.cs new file mode 100644 index 0000000..7928426 --- /dev/null +++ b/tools/Resend.Cli/Broadcast/BroadcastClickedLinksCommand.cs @@ -0,0 +1,66 @@ +using McMaster.Extensions.CommandLineUtils; +using Spectre.Console; +using System.ComponentModel.DataAnnotations; +using System.Text.Json; + +namespace Resend.Cli.Broadcast; + +/// +[Command( "clicked-links", Description = "Lists the clicked links of a broadcast" )] +public class BroadcastClickedLinksCommand +{ + private readonly IResend _resend; + + /// + [Argument( 0, Description = "Broadcast identifier" )] + [Required] + public Guid? BroadcastId { get; set; } + + /// + [Option( "-j|--json", CommandOptionType.NoValue, Description = "Emit output as JSON array" )] + public bool InJson { get; set; } + + + /// + public BroadcastClickedLinksCommand( IResend resend ) + { + _resend = resend; + } + + + /// + public async Task OnExecuteAsync() + { + var res = await _resend.BroadcastClickedLinksAsync( this.BroadcastId!.Value ); + var rows = res.Content.Data; + + if ( this.InJson == true ) + { + var jso = new JsonSerializerOptions() { WriteIndented = true }; + var json = JsonSerializer.Serialize( rows, jso ); + + Console.WriteLine( json ); + } + else + { + var table = new Table(); + table.Border = TableBorder.SimpleHeavy; + table.AddColumn( "Url" ); + table.AddColumn( "Clicks" ); + table.AddColumn( "Unique clicks" ); + + foreach ( var c in rows ) + { + table.AddRow( + new Markup( c.Url ), + new Markup( c.Clicks.ToString() ), + new Markup( c.UniqueClicks.ToString() ) + ); + } + + AnsiConsole.Write( table ); + } + + return 0; + } +} diff --git a/tools/Resend.Cli/BroadcastCommand.cs b/tools/Resend.Cli/BroadcastCommand.cs index 4ee2259..0c94715 100644 --- a/tools/Resend.Cli/BroadcastCommand.cs +++ b/tools/Resend.Cli/BroadcastCommand.cs @@ -6,6 +6,7 @@ namespace Resend.Cli; [Command( "broadcast", Description = "Broadcast management" )] [Subcommand( typeof( Broadcast.BroadcastAddCommand ))] [Subcommand( typeof( Broadcast.BroadcastCancelCommand ) )] +[Subcommand( typeof( Broadcast.BroadcastClickedLinksCommand ) )] [Subcommand( typeof( Broadcast.BroadcastDeleteCommand ) )] [Subcommand( typeof( Broadcast.BroadcastListCommand ) )] [Subcommand( typeof( Broadcast.BroadcastRetrieveCommand ))] From bf8440797bf816e89b38fbb27224e916d407d8cd Mon Sep 17 00:00:00 2001 From: dielduarte Date: Mon, 24 Aug 2026 11:14:42 -0300 Subject: [PATCH 3/3] fix: add pagination options and escape URL markup in clicked-links CLI command --- .../Broadcast/BroadcastClickedLinksCommand.cs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tools/Resend.Cli/Broadcast/BroadcastClickedLinksCommand.cs b/tools/Resend.Cli/Broadcast/BroadcastClickedLinksCommand.cs index 7928426..dd050ed 100644 --- a/tools/Resend.Cli/Broadcast/BroadcastClickedLinksCommand.cs +++ b/tools/Resend.Cli/Broadcast/BroadcastClickedLinksCommand.cs @@ -16,6 +16,18 @@ public class BroadcastClickedLinksCommand [Required] public Guid? BroadcastId { get; set; } + /// + [Option( "-l|--limit", CommandOptionType.SingleValue, Description = "Number of links to return" )] + public int? Limit { get; set; } + + /// + [Option( "-b|--before", CommandOptionType.SingleValue, Description = "Links before cursor" )] + public string? BeforeId { get; set; } + + /// + [Option( "-a|--after", CommandOptionType.SingleValue, Description = "Links after cursor" )] + public string? AfterId { get; set; } + /// [Option( "-j|--json", CommandOptionType.NoValue, Description = "Emit output as JSON array" )] public bool InJson { get; set; } @@ -31,7 +43,14 @@ public BroadcastClickedLinksCommand( IResend resend ) /// public async Task OnExecuteAsync() { - var res = await _resend.BroadcastClickedLinksAsync( this.BroadcastId!.Value ); + var q = new PaginatedQuery() + { + Limit = this.Limit, + Before = this.BeforeId, + After = this.AfterId, + }; + + var res = await _resend.BroadcastClickedLinksAsync( this.BroadcastId!.Value, q ); var rows = res.Content.Data; if ( this.InJson == true ) @@ -52,7 +71,7 @@ public async Task OnExecuteAsync() foreach ( var c in rows ) { table.AddRow( - new Markup( c.Url ), + new Markup( Markup.Escape( c.Url ) ), new Markup( c.Clicks.ToString() ), new Markup( c.UniqueClicks.ToString() ) );