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
27 changes: 27 additions & 0 deletions src/Resend/EmailShareResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.Text.Json.Serialization;

namespace Resend;

/// <summary>
/// Result of creating a shareable link for a sent or received email.
/// </summary>
public class EmailShareResult
{
/// <summary>
/// Object type discriminator.
/// </summary>
[JsonPropertyName( "object" )]
public string Object { get; set; } = default!;

/// <summary>
/// Email identifier.
/// </summary>
[JsonPropertyName( "id" )]
public Guid Id { get; set; }

/// <summary>
/// Shareable link URL.
/// </summary>
[JsonPropertyName( "url" )]
public string Url { get; set; } = default!;
}
20 changes: 20 additions & 0 deletions src/Resend/IResend.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,26 @@ public interface IResend
/// <see href="https://www.resend.com/docs/api-reference/emails/cancel-email"/>
Task<ResendResponse> EmailCancelAsync( Guid emailId, CancellationToken cancellationToken = default );

/// <summary>
/// Creates a shareable link for a sent or received email.
/// </summary>
/// <param name="emailId">
/// Email identifier.
/// </param>
/// <param name="expiresIn">
/// How long the shareable link remains valid, as a human-readable duration (for example
/// <c>"10m"</c>, <c>"2 hours"</c>, <c>"1 day"</c> or <c>"1h 30m"</c>). Defaults to
/// <c>"48h"</c> and is capped at 48 hours.
/// </param>
/// <param name="cancellationToken">
/// Cancellation token.
/// </param>
/// <returns>
/// Shareable link result.
/// </returns>
/// <see href="https://www.resend.com/docs/api-reference/emails/share-email"/>
Task<ResendResponse<EmailShareResult>> EmailShareAsync( Guid emailId, string? expiresIn = null, CancellationToken cancellationToken = default );

/// <summary>
/// Lists email attachments from a sent email.
/// </summary>
Expand Down
12 changes: 12 additions & 0 deletions src/Resend/Payloads/EmailShareRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;

namespace Resend.Payloads;

/// <summary />
public class EmailShareRequest
{
/// <summary />
[JsonPropertyName( "expires_in" )]
[JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )]
public string? ExpiresIn { get; set; }
}
14 changes: 14 additions & 0 deletions src/Resend/ResendClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,20 @@ public ResendClient( IOptionsSnapshot<ResendClientOptions> options, HttpClient h
}


/// <inheritdoc />
public Task<ResendResponse<EmailShareResult>> EmailShareAsync( Guid emailId, string? expiresIn = null, CancellationToken cancellationToken = default )
{
var path = $"emails/{emailId}/share";
var req = new HttpRequestMessage( HttpMethod.Post, path );
req.Content = JsonContent.Create( new EmailShareRequest()
{
ExpiresIn = expiresIn,
} );

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


/// <inheritdoc />
public Task<ResendResponse<Domain>> DomainAddAsync( string domainName, DeliveryRegion? region = null, CancellationToken cancellationToken = default )
{
Expand Down
66 changes: 66 additions & 0 deletions tests/Resend.Tests/ResendClientTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc.Testing;
using Resend.ApiServer;
using System.Net;

namespace Resend.Tests;

Expand Down Expand Up @@ -127,6 +128,71 @@ public async Task EmailCancel()
}


/// <summary />
[Fact]
public async Task EmailShareDefaultExpiresIn()
{
var emailId = Guid.NewGuid();

var resp = await _resend.EmailShareAsync( emailId );

Assert.NotNull( resp );
Assert.True( resp.Success );
Assert.NotNull( resp.Content );
Assert.Equal( emailId, resp.Content.Id );
Assert.False( string.IsNullOrWhiteSpace( resp.Content.Url ) );
}


/// <summary />
[Theory]
[InlineData( "10m" )]
[InlineData( "2 hours" )]
[InlineData( "1 day" )]
[InlineData( "1h 30m" )]
[InlineData( "48h" )]
public async Task EmailShareCustomExpiresIn( string expiresIn )
{
var emailId = Guid.NewGuid();

var resp = await _resend.EmailShareAsync( emailId, expiresIn );

Assert.NotNull( resp );
Assert.True( resp.Success );
Assert.NotNull( resp.Content );
Assert.Equal( emailId, resp.Content.Id );
Assert.False( string.IsNullOrWhiteSpace( resp.Content.Url ) );
}


/// <summary />
[Theory]
[InlineData( "banana" )]
[InlineData( "72h" )]
[InlineData( "3 days" )]
[InlineData( "99999999999999999999h" )]
public async Task EmailShareRejectsInvalidExpiresIn( string expiresIn )
{
var emailId = Guid.NewGuid();

var ex = await Assert.ThrowsAsync<ResendException>( () => _resend.EmailShareAsync( emailId, expiresIn ) );

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


/// <summary />
[Fact]
public async Task EmailShareNotFound()
{
var ex = await Assert.ThrowsAsync<ResendException>( () => _resend.EmailShareAsync( Guid.Empty ) );

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


/// <summary />
[Fact]
public async Task DomainList()
Expand Down
50 changes: 50 additions & 0 deletions tools/Resend.ApiServer/Controllers/EmailController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -176,4 +176,54 @@ public ObjectId EmailCancel( [FromRoute] Guid id )
Id = id,
};
}


/// <summary>
/// The fake server has no real duration parser -- it isn't the API, so it shouldn't
/// try to replicate the API's validation grammar. It only needs to return realistic
/// responses for the fixed set of inputs the test suite exercises. The fake server
/// also has no persisted state, so the well-known empty id doubles as the
/// "email not found" fixture for tests.
/// </summary>
[HttpPost]
[Route( "emails/{id}/share" )]
public ActionResult<EmailShareResult> EmailShare( [FromRoute] Guid id, [FromBody] EmailShareRequest? request )
{
_logger.LogDebug( "EmailShare" );

if ( id == Guid.Empty )
{
return NotFound( new ErrorResponse()
{
StatusCode = (int) HttpStatusCode.NotFound,
ErrorType = ErrorType.NotFound,
Message = "Email not found",
} );
}

var expiresIn = request?.ExpiresIn ?? "48h";

if ( ValidExpiresIn.Contains( expiresIn ) == false )
{
return UnprocessableEntity( new ErrorResponse()
{
StatusCode = (int) HttpStatusCode.UnprocessableEntity,
ErrorType = ErrorType.ValidationError,
Message = "`expires_in` must be a valid duration, capped at 48 hours.",
} );
}

return new EmailShareResult()
{
Object = "email",
Id = id,
Url = $"https://resend.com/share/{id}",
};
}


private static readonly HashSet<string> ValidExpiresIn = new( StringComparer.OrdinalIgnoreCase )
{
"48h", "10m", "2 hours", "1 day", "1h 30m",
};
}
Loading