commit 261042f92050ef338dfab8f30d1bb65e13c7a0b5 Author: Mathias Beaulieu-Duncan Date: Mon Jul 14 21:58:09 2025 -0400 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c66d0e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ + +**/appsettings.json \ No newline at end of file diff --git a/.idea/.idea.Cakemail/.idea/.gitignore b/.idea/.idea.Cakemail/.idea/.gitignore new file mode 100644 index 0000000..7e807d6 --- /dev/null +++ b/.idea/.idea.Cakemail/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/contentModel.xml +/.idea.Cakemail.iml +/projectSettingsUpdater.xml +/modules.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.Cakemail/.idea/encodings.xml b/.idea/.idea.Cakemail/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/.idea.Cakemail/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.Cakemail/.idea/indexLayout.xml b/.idea/.idea.Cakemail/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.Cakemail/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.Cakemail/.idea/vcs.xml b/.idea/.idea.Cakemail/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/.idea.Cakemail/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/CM.Authentication.Abstractions/CM.Authentication.Abstractions.csproj b/CM.Authentication.Abstractions/CM.Authentication.Abstractions.csproj new file mode 100644 index 0000000..e75400a --- /dev/null +++ b/CM.Authentication.Abstractions/CM.Authentication.Abstractions.csproj @@ -0,0 +1,10 @@ + + + + net9.0 + enable + enable + true + + + diff --git a/CM.Authentication.Abstractions/HttpClientExtensions.cs b/CM.Authentication.Abstractions/HttpClientExtensions.cs new file mode 100644 index 0000000..6bcc2b2 --- /dev/null +++ b/CM.Authentication.Abstractions/HttpClientExtensions.cs @@ -0,0 +1,12 @@ +using System.Net.Http.Headers; + +namespace CM.Authentication.Abstractions; + +public static class HttpClientExtensions +{ + public static HttpClient SetJwtAccessToken(this HttpClient httpClient, JwtTokenResult token) + { + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(token.TokenType.ToString(), token.AccessToken); + return httpClient; + } +} \ No newline at end of file diff --git a/CM.Authentication.Abstractions/IJwtTokenManagerService.cs b/CM.Authentication.Abstractions/IJwtTokenManagerService.cs new file mode 100644 index 0000000..74999f3 --- /dev/null +++ b/CM.Authentication.Abstractions/IJwtTokenManagerService.cs @@ -0,0 +1,6 @@ +namespace CM.Authentication.Abstractions; + +public interface IJwtTokenManagerService +{ + Task GetTokenAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/CM.Authentication.Abstractions/JwtTokenResult.cs b/CM.Authentication.Abstractions/JwtTokenResult.cs new file mode 100644 index 0000000..2dc0841 --- /dev/null +++ b/CM.Authentication.Abstractions/JwtTokenResult.cs @@ -0,0 +1,13 @@ +namespace CM.Authentication.Abstractions; + +public class JwtTokenResult +{ + public required string AccessToken { get; set; } + public required TokenType TokenType { get; set; } + public required int ExpiresIn { get; set; } + public required string RefreshToken { get; set; } + public required IEnumerable Accounts { get; set; } + + // helpers + public required DateTime ExpiresAt { get; set; } +} \ No newline at end of file diff --git a/CM.Authentication.Abstractions/TokenType.cs b/CM.Authentication.Abstractions/TokenType.cs new file mode 100644 index 0000000..a94e6d3 --- /dev/null +++ b/CM.Authentication.Abstractions/TokenType.cs @@ -0,0 +1,6 @@ +namespace CM.Authentication.Abstractions; + +public enum TokenType +{ + Bearer +} \ No newline at end of file diff --git a/CM.Authentication/AuthenticationOptions.cs b/CM.Authentication/AuthenticationOptions.cs new file mode 100644 index 0000000..68a31e8 --- /dev/null +++ b/CM.Authentication/AuthenticationOptions.cs @@ -0,0 +1,10 @@ +namespace CM.Authentication; + +public class AuthenticationOptions +{ + public required string TokenEndpoint { get; set; } + public required string Username { get; set; } + public required string Password { get; set; } + + public JwtTokenCacheOptions CacheOptions { get; set; } = new JwtTokenCacheOptions(); +} \ No newline at end of file diff --git a/CM.Authentication/CM.Authentication.csproj b/CM.Authentication/CM.Authentication.csproj new file mode 100644 index 0000000..4b66efb --- /dev/null +++ b/CM.Authentication/CM.Authentication.csproj @@ -0,0 +1,20 @@ + + + + net9.0 + enable + enable + true + true + + + + + + + + + + + + diff --git a/CM.Authentication/JwtTokenCacheOptions.cs b/CM.Authentication/JwtTokenCacheOptions.cs new file mode 100644 index 0000000..2e63a3b --- /dev/null +++ b/CM.Authentication/JwtTokenCacheOptions.cs @@ -0,0 +1,7 @@ +namespace CM.Authentication; + +public class JwtTokenCacheOptions +{ + public uint ExpirationOffset { get; set; } = 15; + public string CacheKey { get; set; } = "CakemailJwtTokenManager.JwtTokenResult"; +} \ No newline at end of file diff --git a/CM.Authentication/JwtTokenManagerJsonContext.cs b/CM.Authentication/JwtTokenManagerJsonContext.cs new file mode 100644 index 0000000..9578bfb --- /dev/null +++ b/CM.Authentication/JwtTokenManagerJsonContext.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace CM.Authentication; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, + PropertyNameCaseInsensitive = true +)] +[JsonSerializable(typeof(JwtTokenResponse))] +public partial class JwtTokenManagerJsonContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/CM.Authentication/JwtTokenManagerService.cs b/CM.Authentication/JwtTokenManagerService.cs new file mode 100644 index 0000000..611acad --- /dev/null +++ b/CM.Authentication/JwtTokenManagerService.cs @@ -0,0 +1,83 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using CM.Authentication.Abstractions; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CM.Authentication; + +public class JwtTokenManagerService(IOptions options, IHttpClientFactory httpClientFactory, IMemoryCache? memoryCache, ILogger? logger) + : IJwtTokenManagerService +{ + private readonly AuthenticationOptions _options = options.Value; + private readonly JwtTokenCacheOptions _cacheOptions = options.Value.CacheOptions; + private readonly HttpClient _httpClient = httpClientFactory.CreateClient(); + private readonly TimeSpan _cacheExpirationOffset = TimeSpan.FromSeconds(options.Value.CacheOptions.ExpirationOffset); + + public async Task GetTokenAsync(CancellationToken cancellationToken = default) + { + if (memoryCache != null) + { + var memoryGetValueResult = memoryCache.TryGetValue(_cacheOptions.CacheKey, out JwtTokenResult? cachedToken); + if (memoryGetValueResult && null != cachedToken) + { + return cachedToken; + } + } + + var formContentKeyValues = new List>() + { + new ("grant_type", "password"), + new ("username", _options.Username), + new ("password", _options.Password) + }; + + var formContent = new FormUrlEncodedContent(formContentKeyValues); + + var request = new HttpRequestMessage(HttpMethod.Post, _options.TokenEndpoint) + { + Content = formContent + }; + + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + var response = await _httpClient.SendAsync(request, cancellationToken); + + response.EnsureSuccessStatusCode(); + + var tokenResponse = await response.Content.ReadFromJsonAsync(JwtTokenManagerJsonContext.Default.JwtTokenResponse, cancellationToken); + + if (tokenResponse is null) + throw new InvalidOperationException("Failed to deserialize the token response content."); + + var parsedResult = Enum.TryParse(tokenResponse.TokenType, true, out var tokenType); + + if (parsedResult == false) + throw new InvalidOperationException($"Unsupported token type: {tokenResponse.TokenType}"); + + var now = DateTime.UtcNow; + var expiration = TimeSpan.FromSeconds(tokenResponse.ExpiresIn); + var expiresAt = now.Add(expiration); + var result = new JwtTokenResult + { + AccessToken = tokenResponse.AccessToken, + TokenType = tokenType, + Accounts = tokenResponse.Accounts, + RefreshToken = tokenResponse.RefreshToken, + ExpiresIn = tokenResponse.ExpiresIn, + + ExpiresAt = expiresAt, + }; + + if (null != memoryCache && expiration < _cacheExpirationOffset) + { + logger?.LogWarning("Caching is enable but the token expiration time [{expiration}] is less than the expiration offset [{cacheExpirationOffset}]. Caching is ignored, please validate your authorization server configuration and the {className} cache expiration offset configuration", + expiration, _cacheExpirationOffset, nameof(JwtTokenManagerService)); + } + else + memoryCache?.Set(_cacheOptions.CacheKey, result, expiration.Subtract(_cacheExpirationOffset)); + + return result; + } +} \ No newline at end of file diff --git a/CM.Authentication/JwtTokenResponse.cs b/CM.Authentication/JwtTokenResponse.cs new file mode 100644 index 0000000..c922b1e --- /dev/null +++ b/CM.Authentication/JwtTokenResponse.cs @@ -0,0 +1,12 @@ +using CM.Authentication.Abstractions; + +namespace CM.Authentication; + +public class JwtTokenResponse +{ + public required string AccessToken { get; set; } + public required string TokenType { get; set; } + public required int ExpiresIn { get; set; } + public required string RefreshToken { get; set; } + public required IEnumerable Accounts { get; set; } +} \ No newline at end of file diff --git a/CM.Authentication/ServiceCollectionExtensions.cs b/CM.Authentication/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..da4eb5a --- /dev/null +++ b/CM.Authentication/ServiceCollectionExtensions.cs @@ -0,0 +1,28 @@ +using CM.Authentication.Abstractions; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CM.Authentication; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddCakemailJwtTokenManager(this IServiceCollection services, string configurationSection = "Cakemail:Authentication") + { + services.AddOptions() + .BindConfiguration(configurationSection); + + services.AddSingleton(serviceProvider => + { + var options = serviceProvider.GetRequiredService>(); + var httpClientFactory = serviceProvider.GetRequiredService(); + var memoryCache = serviceProvider.GetService(); + var logger = serviceProvider.GetService>(); + + return new JwtTokenManagerService(options, httpClientFactory, memoryCache, logger); + }); + + return services; + } +} \ No newline at end of file diff --git a/CM.Authentication/SettingsJsonSerializerContext.cs b/CM.Authentication/SettingsJsonSerializerContext.cs new file mode 100644 index 0000000..4595ce8 --- /dev/null +++ b/CM.Authentication/SettingsJsonSerializerContext.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace CM.Authentication; + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(AuthenticationOptions))] +internal partial class AppJsonSerializerContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/CM.Sdk/CM.Sdk.csproj b/CM.Sdk/CM.Sdk.csproj new file mode 100644 index 0000000..393bb96 --- /dev/null +++ b/CM.Sdk/CM.Sdk.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + true + + + + + + + + + + + + diff --git a/CM.Sdk/ServiceCollectionExtensions.cs b/CM.Sdk/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..0a1dc4f --- /dev/null +++ b/CM.Sdk/ServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +using CM.TransactionalEmail; +using CM.Authentication; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace CM.Sdk; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddCakemailSdk(this IServiceCollection services) + { + services.AddCakemailJwtTokenManager(); + services.AddTransient(); + + return services; + } +} \ No newline at end of file diff --git a/CM.Shared/CM.Shared.csproj b/CM.Shared/CM.Shared.csproj new file mode 100644 index 0000000..e75400a --- /dev/null +++ b/CM.Shared/CM.Shared.csproj @@ -0,0 +1,10 @@ + + + + net9.0 + enable + enable + true + + + diff --git a/CM.Shared/CustomAttribute.cs b/CM.Shared/CustomAttribute.cs new file mode 100644 index 0000000..219e690 --- /dev/null +++ b/CM.Shared/CustomAttribute.cs @@ -0,0 +1,7 @@ +namespace CM.Shared; + +public class CustomAttribute +{ + public required string Name { get; set; } + public required string Value { get; set; } +} \ No newline at end of file diff --git a/CM.Tests/.gitignore b/CM.Tests/.gitignore new file mode 100644 index 0000000..d7be6ac --- /dev/null +++ b/CM.Tests/.gitignore @@ -0,0 +1 @@ +appsettings.json \ No newline at end of file diff --git a/CM.Tests/CM.Tests.csproj b/CM.Tests/CM.Tests.csproj new file mode 100644 index 0000000..c6dc3f5 --- /dev/null +++ b/CM.Tests/CM.Tests.csproj @@ -0,0 +1,44 @@ + + + + net9.0 + enable + enable + false + true + + true + true + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + PreserveNewest + + + diff --git a/CM.Tests/TestFixture.cs b/CM.Tests/TestFixture.cs new file mode 100644 index 0000000..25f0157 --- /dev/null +++ b/CM.Tests/TestFixture.cs @@ -0,0 +1,37 @@ +using CM.Authentication; +using CM.Sdk; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CM.Tests; + +public class TestFixture +{ + public IServiceProvider ServiceProvider { get; private set; } + public IConfiguration Configuration { get; private set; } + + public TestFixture() + { + var configurationBuilder = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddEnvironmentVariables(); + + Configuration = configurationBuilder.Build(); + + + var services = new ServiceCollection(); + + services.AddSingleton(Configuration); + + ConfigureServices(services); + ServiceProvider = services.BuildServiceProvider(); + } + + private void ConfigureServices(IServiceCollection services) + { + services.AddHttpClient(); + services.AddCakemailSdk(); + } +} \ No newline at end of file diff --git a/CM.Tests/TransactionalEmailTests.cs b/CM.Tests/TransactionalEmailTests.cs new file mode 100644 index 0000000..32872ee --- /dev/null +++ b/CM.Tests/TransactionalEmailTests.cs @@ -0,0 +1,54 @@ +using CM.Authentication.Abstractions; +using CM.TransactionalEmail; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace CM.Tests; + +public class TransactionalEmailTests(TestFixture fixture, ITestOutputHelper output) : IClassFixture +{ + [Fact] + public async Task AuthenticationAsync() + { + var jwtTokenManagerService = fixture.ServiceProvider.GetRequiredService(); + + var response = await jwtTokenManagerService.GetTokenAsync(); + output.WriteLine("Successfully receive token type {0}", response.TokenType.ToString()); + } + + [Fact] + public async Task SendEmailAsync() + { + var configuration = fixture.ServiceProvider.GetRequiredService(); + var transactionalEmailService = fixture.ServiceProvider.GetRequiredService(); + + var testRecipient = configuration["TestRecipient"]; + var senderId = configuration["SenderId"]; + + Assert.NotNull(testRecipient); + Assert.NotNull(senderId); + + await transactionalEmailService.SendEmailAsync(new SendEmailOptions() + { + Email = testRecipient, + Sender = new Sender + { + Id = senderId + }, + Content = new EmailContent + { + Subject = "Send Email Test", + Text = "Test Email", + Encoding = EmailEncoding.Utf8, + }, + Tracking = new TrackingOptions + { + ClicksHtml = false, + ClicksText = false, + Opens = true + }, + Queue = 0 + }); + } +} \ No newline at end of file diff --git a/CM.Tests/appsettings.example.json b/CM.Tests/appsettings.example.json new file mode 100644 index 0000000..8916a9e --- /dev/null +++ b/CM.Tests/appsettings.example.json @@ -0,0 +1,15 @@ +{ + "TestRecipient": "", + "SenderId": "", + "Cakemail": { + "Authentication": { + "TokenEndpoint": "", + "Username": "", + "Password": "", + "Cache": { + "ExpirationOffset": 15, + "CacheKey": "CakemailJwtTokenManager.JwtTokenResult" + } + } + } +} \ No newline at end of file diff --git a/CM.TransactionalEmail/AttachmentType.cs b/CM.TransactionalEmail/AttachmentType.cs new file mode 100644 index 0000000..0946341 --- /dev/null +++ b/CM.TransactionalEmail/AttachmentType.cs @@ -0,0 +1,35 @@ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace CM.TransactionalEmail; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AttachmentType +{ + [EnumMember(Value = "csv")] + Csv, + + [EnumMember(Value = "doc")] + Doc, + + [EnumMember(Value = "docx")] + Docx, + + [EnumMember(Value = "calendar")] + Calendar, + + [EnumMember(Value = "jpeg")] + Jpeg, + + [EnumMember(Value = "pdf")] + Pdf, + + [EnumMember(Value = "png")] + Png, + + [EnumMember(Value = "xls")] + Xls, + + [EnumMember(Value = "xlsx")] + Xlsx +} \ No newline at end of file diff --git a/CM.TransactionalEmail/CM.TransactionalEmail.csproj b/CM.TransactionalEmail/CM.TransactionalEmail.csproj new file mode 100644 index 0000000..0f4e599 --- /dev/null +++ b/CM.TransactionalEmail/CM.TransactionalEmail.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + true + + + + + + + + + + + + diff --git a/CM.TransactionalEmail/EmailAttachment.cs b/CM.TransactionalEmail/EmailAttachment.cs new file mode 100644 index 0000000..dd7c85b --- /dev/null +++ b/CM.TransactionalEmail/EmailAttachment.cs @@ -0,0 +1,8 @@ +namespace CM.TransactionalEmail; + +public class EmailAttachment +{ + public required string Filename { get; set; } + public AttachmentType Type { get; set; } + public required string Content { get; set; } +} \ No newline at end of file diff --git a/CM.TransactionalEmail/EmailContent.cs b/CM.TransactionalEmail/EmailContent.cs new file mode 100644 index 0000000..11b8d16 --- /dev/null +++ b/CM.TransactionalEmail/EmailContent.cs @@ -0,0 +1,13 @@ +using CM.Shared; + +namespace CM.TransactionalEmail; + +public class EmailContent +{ + public required string Subject { get; set; } + public string? Html { get; set; } + public string? Text { get; set; } + public EmailTemplate? Template { get; set; } + public EmailEncoding Encoding { get; set; } = EmailEncoding.Utf8; + public IEnumerable? CustomAttributes { get; set; } +} \ No newline at end of file diff --git a/CM.TransactionalEmail/EmailEncoding.cs b/CM.TransactionalEmail/EmailEncoding.cs new file mode 100644 index 0000000..a298730 --- /dev/null +++ b/CM.TransactionalEmail/EmailEncoding.cs @@ -0,0 +1,118 @@ +using System.Text.Json.Serialization; + +namespace CM.TransactionalEmail; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum EmailEncoding +{ + [JsonStringEnumMemberName("utf-8")] + Utf8, + + [JsonStringEnumMemberName("armscii-8")] + Armscii8, + + [JsonStringEnumMemberName("ascii")] + Ascii, + + [JsonStringEnumMemberName("big-5")] + Big5, + + [JsonStringEnumMemberName("cp51932")] + Cp51932, + + [JsonStringEnumMemberName("cp866")] + Cp866, + + [JsonStringEnumMemberName("cp936")] + Cp936, + + [JsonStringEnumMemberName("euc-cn")] + EucCn, + + [JsonStringEnumMemberName("euc-jp")] + EucJp, + + [JsonStringEnumMemberName("eucjp-win")] + EucJpWin, + + [JsonStringEnumMemberName("euc-kr")] + EucKr, + + [JsonStringEnumMemberName("euc-tw")] + EucTw, + + [JsonStringEnumMemberName("hz")] + Hz, + + [JsonStringEnumMemberName("iso-2022-jp")] + Iso2022Jp, + + [JsonStringEnumMemberName("iso-2022-jp-ms")] + Iso2022JpMs, + + [JsonStringEnumMemberName("iso-2022-kr")] + Iso2022Kr, + + [JsonStringEnumMemberName("iso-8859-1")] + Iso88591, + + [JsonStringEnumMemberName("iso-8859-10")] + Iso885910, + + [JsonStringEnumMemberName("iso-8859-13")] + Iso885913, + + [JsonStringEnumMemberName("iso-8859-14")] + Iso885914, + + [JsonStringEnumMemberName("iso-8859-15")] + Iso885915, + + [JsonStringEnumMemberName("iso-8859-16")] + Iso885916, + + [JsonStringEnumMemberName("iso-8859-2")] + Iso88592, + + [JsonStringEnumMemberName("iso-8859-3")] + Iso88593, + + [JsonStringEnumMemberName("iso-8859-4")] + Iso88594, + + [JsonStringEnumMemberName("iso-8859-5")] + Iso88595, + + [JsonStringEnumMemberName("iso-8859-6")] + Iso88596, + + [JsonStringEnumMemberName("iso-8859-7")] + Iso88597, + + [JsonStringEnumMemberName("iso-8859-8")] + Iso88598, + + [JsonStringEnumMemberName("iso-8859-9")] + Iso88599, + + [JsonStringEnumMemberName("jis")] + Jis, + + [JsonStringEnumMemberName("koi8-r")] + Koi8R, + + [JsonStringEnumMemberName("sjis")] + Sjis, + + [JsonStringEnumMemberName("sjis-win")] + SjisWin, + + [JsonStringEnumMemberName("uhc")] + Uhc, + + [JsonStringEnumMemberName("windows-1251")] + Windows1251, + + [JsonStringEnumMemberName("windows-1252")] + Windows1252 +} \ No newline at end of file diff --git a/CM.TransactionalEmail/EmailTemplate.cs b/CM.TransactionalEmail/EmailTemplate.cs new file mode 100644 index 0000000..7247ffb --- /dev/null +++ b/CM.TransactionalEmail/EmailTemplate.cs @@ -0,0 +1,9 @@ +using CM.Shared; + +namespace CM.TransactionalEmail; + +public class EmailTemplate +{ + public required int Id { get; set; } + public required IEnumerable CustomAttributes { get; set; } +} \ No newline at end of file diff --git a/CM.TransactionalEmail/SendEmailOptions.cs b/CM.TransactionalEmail/SendEmailOptions.cs new file mode 100644 index 0000000..5866214 --- /dev/null +++ b/CM.TransactionalEmail/SendEmailOptions.cs @@ -0,0 +1,14 @@ +using CM.Shared; + +namespace CM.TransactionalEmail; + +public class SendEmailOptions +{ + public required string Email { get; set; } + public required Sender Sender { get; set; } + public required EmailContent Content { get; set; } + public long? GroupId { get; set; } + public TrackingOptions? Tracking { get; set; } + public IEnumerable? AdditionalHeaders { get; set; } + public long? Queue { get; set; } +} \ No newline at end of file diff --git a/CM.TransactionalEmail/Sender.cs b/CM.TransactionalEmail/Sender.cs new file mode 100644 index 0000000..19ea6c4 --- /dev/null +++ b/CM.TransactionalEmail/Sender.cs @@ -0,0 +1,7 @@ +namespace CM.TransactionalEmail; + +public class Sender +{ + public required string Id { get; set; } + public string? Name { get; set; } +} \ No newline at end of file diff --git a/CM.TransactionalEmail/TrackingOptions.cs b/CM.TransactionalEmail/TrackingOptions.cs new file mode 100644 index 0000000..737c98d --- /dev/null +++ b/CM.TransactionalEmail/TrackingOptions.cs @@ -0,0 +1,8 @@ +namespace CM.TransactionalEmail; + +public class TrackingOptions +{ + public bool? Opens { get; set; } + public bool? ClicksHtml { get; set; } + public bool? ClicksText { get; set; } +} \ No newline at end of file diff --git a/CM.TransactionalEmail/TransactionalEmailJsonSerializerContext.cs b/CM.TransactionalEmail/TransactionalEmailJsonSerializerContext.cs new file mode 100644 index 0000000..0bffee9 --- /dev/null +++ b/CM.TransactionalEmail/TransactionalEmailJsonSerializerContext.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace CM.TransactionalEmail; + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] +[JsonSerializable(typeof(SendEmailOptions))] +[JsonSerializable(typeof(EmailEncoding))] +internal partial class TransactionEmailJsonSerializerContext: JsonSerializerContext +{ + +} \ No newline at end of file diff --git a/CM.TransactionalEmail/TransactionalEmailService.cs b/CM.TransactionalEmail/TransactionalEmailService.cs new file mode 100644 index 0000000..dee4dd8 --- /dev/null +++ b/CM.TransactionalEmail/TransactionalEmailService.cs @@ -0,0 +1,28 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization.Metadata; +using CM.Authentication.Abstractions; + +namespace CM.TransactionalEmail; + +public class TransactionalEmailService(IHttpClientFactory httpClientFactory, IJwtTokenManagerService jwtTokenManagerService) +{ + private readonly string _endpoint = "https://api.cakemail.dev"; + private readonly HttpClient _httpClient = httpClientFactory.CreateClient(); + + public async Task SendEmailAsync(SendEmailOptions options, CancellationToken cancellationToken = default) + { + var endpoint = $"{_endpoint}/emails"; + + var token = await jwtTokenManagerService.GetTokenAsync(cancellationToken); + _httpClient.SetJwtAccessToken(token); + + var request = new HttpRequestMessage(HttpMethod.Post, endpoint) + { + Content = JsonContent.Create(options, TransactionEmailJsonSerializerContext.Default.SendEmailOptions) + }; + + var response = await _httpClient.SendAsync(request, cancellationToken); + + response.EnsureSuccessStatusCode(); + } +} \ No newline at end of file diff --git a/Cakemail.sln b/Cakemail.sln new file mode 100644 index 0000000..33fb264 --- /dev/null +++ b/Cakemail.sln @@ -0,0 +1,46 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CM.Sdk", "CM.Sdk\CM.Sdk.csproj", "{59009AC6-0D2E-4E0E-B548-28BB8E773EA0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CM.Authentication", "CM.Authentication\CM.Authentication.csproj", "{98B1EF3D-F322-4E86-9A78-D0E27E6F56B2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CM.TransactionalEmail", "CM.TransactionalEmail\CM.TransactionalEmail.csproj", "{27CBC083-5AE5-46BC-8B65-EAA39A16E8C8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CM.Authentication.Abstractions", "CM.Authentication.Abstractions\CM.Authentication.Abstractions.csproj", "{F55E3C54-533F-4630-8B3A-ECD46AD530F7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CM.Tests", "CM.Tests\CM.Tests.csproj", "{AA54F60C-491D-4142-A70D-C27E2803521D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CM.Shared", "CM.Shared\CM.Shared.csproj", "{30DA9908-310F-4E04-87B3-4D8FE40F818B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {59009AC6-0D2E-4E0E-B548-28BB8E773EA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {59009AC6-0D2E-4E0E-B548-28BB8E773EA0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {59009AC6-0D2E-4E0E-B548-28BB8E773EA0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {59009AC6-0D2E-4E0E-B548-28BB8E773EA0}.Release|Any CPU.Build.0 = Release|Any CPU + {98B1EF3D-F322-4E86-9A78-D0E27E6F56B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {98B1EF3D-F322-4E86-9A78-D0E27E6F56B2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {98B1EF3D-F322-4E86-9A78-D0E27E6F56B2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {98B1EF3D-F322-4E86-9A78-D0E27E6F56B2}.Release|Any CPU.Build.0 = Release|Any CPU + {27CBC083-5AE5-46BC-8B65-EAA39A16E8C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {27CBC083-5AE5-46BC-8B65-EAA39A16E8C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {27CBC083-5AE5-46BC-8B65-EAA39A16E8C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {27CBC083-5AE5-46BC-8B65-EAA39A16E8C8}.Release|Any CPU.Build.0 = Release|Any CPU + {F55E3C54-533F-4630-8B3A-ECD46AD530F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F55E3C54-533F-4630-8B3A-ECD46AD530F7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F55E3C54-533F-4630-8B3A-ECD46AD530F7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F55E3C54-533F-4630-8B3A-ECD46AD530F7}.Release|Any CPU.Build.0 = Release|Any CPU + {AA54F60C-491D-4142-A70D-C27E2803521D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA54F60C-491D-4142-A70D-C27E2803521D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA54F60C-491D-4142-A70D-C27E2803521D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA54F60C-491D-4142-A70D-C27E2803521D}.Release|Any CPU.Build.0 = Release|Any CPU + {30DA9908-310F-4E04-87B3-4D8FE40F818B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {30DA9908-310F-4E04-87B3-4D8FE40F818B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {30DA9908-310F-4E04-87B3-4D8FE40F818B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {30DA9908-310F-4E04-87B3-4D8FE40F818B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Cakemail.sln.DotSettings.user b/Cakemail.sln.DotSettings.user new file mode 100644 index 0000000..8f644e6 --- /dev/null +++ b/Cakemail.sln.DotSettings.user @@ -0,0 +1,15 @@ + + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + <SessionState ContinuousTestingMode="0" IsActive="True" Name="AuthenticationAsync" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::AA54F60C-491D-4142-A70D-C27E2803521D::net8.0::CM.Tests.TransactionalEmailTests.AuthenticationAsync</TestId> + </TestAncestor> +</SessionState> + <SessionState ContinuousTestingMode="0" Name="SendEmailAsync" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::AA54F60C-491D-4142-A70D-C27E2803521D::net9.0::CM.Tests.TransactionalEmailTests.SendEmailAsync</TestId> + </TestAncestor> +</SessionState> \ No newline at end of file