diff --git a/src/Passwordless/Helpers/PasswordlessSerializerContext.cs b/src/Passwordless/Helpers/PasswordlessSerializerContext.cs
index 3c1b8db..25b0073 100644
--- a/src/Passwordless/Helpers/PasswordlessSerializerContext.cs
+++ b/src/Passwordless/Helpers/PasswordlessSerializerContext.cs
@@ -26,4 +26,5 @@ namespace Passwordless.Helpers;
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(GetEventLogRequest))]
[JsonSerializable(typeof(GetEventLogResponse))]
+[JsonSerializable(typeof(SendMagicLinkApiRequest))]
internal partial class PasswordlessSerializerContext : JsonSerializerContext;
\ No newline at end of file
diff --git a/src/Passwordless/IPasswordlessClient.cs b/src/Passwordless/IPasswordlessClient.cs
index 13c555b..f2143ac 100644
--- a/src/Passwordless/IPasswordlessClient.cs
+++ b/src/Passwordless/IPasswordlessClient.cs
@@ -110,4 +110,12 @@ Task DeleteCredentialAsync(
///
///
Task GetEventLogAsync(GetEventLogRequest request, CancellationToken cancellationToken = default);
+
+ ///
+ /// Sends an email containing a magic link template allowing users to login.
+ ///
+ ///
+ ///
+ ///
+ Task SendMagicLinkAsync(SendMagicLinkRequest request, CancellationToken cancellationToken = default);
}
\ No newline at end of file
diff --git a/src/Passwordless/Models/GetEventLogRequest.cs b/src/Passwordless/Models/GetEventLogRequest.cs
index ef60157..9886f37 100644
--- a/src/Passwordless/Models/GetEventLogRequest.cs
+++ b/src/Passwordless/Models/GetEventLogRequest.cs
@@ -1,5 +1,3 @@
-using System.ComponentModel.DataAnnotations;
-
namespace Passwordless.Models;
///
diff --git a/src/Passwordless/Models/SendMagicLinkRequest.cs b/src/Passwordless/Models/SendMagicLinkRequest.cs
new file mode 100644
index 0000000..0eda740
--- /dev/null
+++ b/src/Passwordless/Models/SendMagicLinkRequest.cs
@@ -0,0 +1,23 @@
+using System;
+using Passwordless.Helpers.Extensions;
+
+namespace Passwordless.Models;
+
+///
+/// Request for sending an email with a link that contains a 1-time-use token to be used for validating signin.
+///
+/// Valid email address that will be the recipient of the magic link email
+/// Url template that needs to contain the token template, . The token template will be replaced with a valid signin token to be sent to the verify sign in token endpoint (https://v4.passwsordless.dev/signin/verify).
+/// Identifier for the user the email is intended for.
+/// Length of time the magic link will be active. Default value will be 15 minutes.
+public record SendMagicLinkRequest(string EmailAddress, string UrlTemplate, string UserId, TimeSpan? TimeToLive)
+{
+ internal SendMagicLinkApiRequest ToRequest() =>
+ new(
+ this.EmailAddress,
+ this.UrlTemplate,
+ this.UserId,
+ this.TimeToLive?.TotalSeconds.Pipe(Convert.ToInt32));
+};
+
+internal record SendMagicLinkApiRequest(string EmailAddress, string UrlTemplate, string UserId, int? TimeToLive);
\ No newline at end of file
diff --git a/src/Passwordless/PasswordlessClient.cs b/src/Passwordless/PasswordlessClient.cs
index 7d5023e..40de3a4 100644
--- a/src/Passwordless/PasswordlessClient.cs
+++ b/src/Passwordless/PasswordlessClient.cs
@@ -115,6 +115,18 @@ public async Task GetEventLogAsync(GetEventLogRequest reque
PasswordlessSerializerContext.Default.GetEventLogResponse,
cancellationToken))!;
+ ///
+ public async Task SendMagicLinkAsync(SendMagicLinkRequest request, CancellationToken cancellationToken = default)
+ {
+ using var response = await _http.PostAsJsonAsync(
+ "magic-links/send",
+ request.ToRequest(),
+ PasswordlessSerializerContext.Default.SendMagicLinkApiRequest,
+ cancellationToken);
+
+ response.EnsureSuccessStatusCode();
+ }
+
///
public async Task GetUsersCountAsync(CancellationToken cancellationToken = default) =>
(await _http.GetFromJsonAsync(
diff --git a/tests/Passwordless.Tests.Infra/TestApi.cs b/tests/Passwordless.Tests.Infra/TestApi.cs
index 7a29aec..51a83fb 100644
--- a/tests/Passwordless.Tests.Infra/TestApi.cs
+++ b/tests/Passwordless.Tests.Infra/TestApi.cs
@@ -96,7 +96,8 @@ public async Task CreateAppAsync()
{
"adminEmail": "test@passwordless.dev",
"eventLoggingIsEnabled": true,
- "eventLoggingRetentionPeriod": 7
+ "eventLoggingRetentionPeriod": 7,
+ "magicLinkEmailMonthlyQuota" : 100
}
""",
Encoding.UTF8,
diff --git a/tests/Passwordless.Tests/MagicLinksTests.cs b/tests/Passwordless.Tests/MagicLinksTests.cs
new file mode 100644
index 0000000..f888721
--- /dev/null
+++ b/tests/Passwordless.Tests/MagicLinksTests.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Passwordless.Models;
+using Passwordless.Tests.Infra;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Passwordless.Tests;
+
+public class MagicLinksTests(TestApiFixture api, ITestOutputHelper testOutput) : ApiTestBase(api, testOutput)
+{
+ [Fact]
+ public async Task I_can_send_a_magic_link_with_a_specified_time_to_live()
+ {
+ // Arrange
+ var passwordless = await Api.CreateClientAsync();
+ var request = new SendMagicLinkRequest("test@passwordless.dev", "https://www.example.com?token=$TOKEN", "user", new TimeSpan(0, 15, 0));
+
+ // Act
+ var action = async () => await passwordless.SendMagicLinkAsync(request, CancellationToken.None);
+
+ // Assert
+ await action.Should().NotThrowAsync();
+ }
+
+ [Fact]
+ public async Task I_can_send_a_magic_link_without_a_time_to_live()
+ {
+ // Arrange
+ var passwordless = await Api.CreateClientAsync();
+ var request = new SendMagicLinkRequest("test@passwordless.dev", "https://www.example.com?token=$TOKEN", "user", null);
+
+ // Act
+ var action = async () => await passwordless.SendMagicLinkAsync(request, CancellationToken.None);
+
+ // Assert
+ await action.Should().NotThrowAsync();
+ }
+}
\ No newline at end of file