diff --git a/Wave.sln.DotSettings b/Wave.sln.DotSettings new file mode 100644 index 0000000..020b38b --- /dev/null +++ b/Wave.sln.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/Wave/Controllers/ApiController.cs b/Wave/Controllers/ApiController.cs index fa073f9..31bf127 100644 --- a/Wave/Controllers/ApiController.cs +++ b/Wave/Controllers/ApiController.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using System.ComponentModel.DataAnnotations; +using AspNetCore.Authentication.ApiKey; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.OutputCaching; using Wave.Data; @@ -33,7 +34,7 @@ public class ApiController(ApplicationDbContext context, IOptions [HttpGet("email/subscriber/{email}")] [Produces("application/json")] - [Authorize("EmailApi")] + [Authorize("EmailApi", AuthenticationSchemes = ApiKeyDefaults.AuthenticationScheme)] [OutputCache(Duration = 60*10)] public async Task, NotFound>> GetEmailSubscriber([EmailAddress] string email) { var subscriber = await context.Set() diff --git a/Wave/Controllers/WebhookController.cs b/Wave/Controllers/WebhookController.cs new file mode 100644 index 0000000..b10777c --- /dev/null +++ b/Wave/Controllers/WebhookController.cs @@ -0,0 +1,63 @@ +using Humanizer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Wave.Data; +using Wave.Data.Api.Mailtrap; + +namespace Wave.Controllers; + +[ApiController] +[Route("/api/[controller]")] +public class WebhookController(ILogger logger, ApplicationDbContext context) : ControllerBase { + [HttpPost("mailtrap/{apiKey}")] + [Authorize("EmailApi", AuthenticationSchemes = "ApiKeyInRoute")] + public async Task Mailtrap(Webhook webhook, string apiKey) { + Console.WriteLine(apiKey); + foreach (var webhookEvent in webhook.Events) { + var subscriber = await context.Set().FirstOrDefaultAsync(s => s.Email == webhookEvent.Email); + + if (subscriber is null) { + logger.LogWarning( + "Received webhook event from mailtrap of type {type}, but failed to find subscriber with E-Mail {email}.", + webhookEvent.Type, webhookEvent.Email); + continue; + } + + // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault + switch (webhookEvent.Type) { + case WebhookEventType.Delivery: + subscriber.LastMailReceived = webhookEvent.EventDateTime; + break; + case WebhookEventType.Open: + subscriber.LastMailOpened = webhookEvent.EventDateTime; + break; + case WebhookEventType.Bounce: + // Store this message in case it develops into a suspension + subscriber.UnsubscribeReason = webhookEvent.Response; + break; + case WebhookEventType.Suspension: + subscriber.Unsubscribed = true; + subscriber.UnsubscribeReason ??= "unknown"; + break; + case WebhookEventType.Unsubscribe: + subscriber.Unsubscribed = true; + subscriber.UnsubscribeReason ??= "User Unsubscribed"; + break; + case WebhookEventType.SpamComplaint: + subscriber.Unsubscribed = true; + subscriber.UnsubscribeReason ??= "User reported as Spam"; + break; + case WebhookEventType.Reject: + subscriber.Unsubscribed = true; + subscriber.UnsubscribeReason ??= webhookEvent.Reason?.Humanize().Titleize() ?? "Rejected"; + break; + } + + await context.SaveChangesAsync(); + } + + + return Ok(); + } +} \ No newline at end of file diff --git a/Wave/Data/Api/Mailtrap/WebhookEvent.cs b/Wave/Data/Api/Mailtrap/WebhookEvent.cs new file mode 100644 index 0000000..4b80889 --- /dev/null +++ b/Wave/Data/Api/Mailtrap/WebhookEvent.cs @@ -0,0 +1,49 @@ +using System.Text.Json.Serialization; + +namespace Wave.Data.Api.Mailtrap; + +public enum WebhookEventType { + Delivery, + SoftBounce, + Bounce, + Suspension, + Unsubscribe, + Open, + SpamComplaint, + Click, + Reject +} + +public record WebhookEvent( + [property:JsonPropertyName("event")] + string EventTypeString, + [property:JsonPropertyName("category")] + string Category, + [property:JsonPropertyName("message_id")] + string MessageId, + [property:JsonPropertyName("event_id")] + string EventId, + [property:JsonPropertyName("email")] + string Email, + [property:JsonPropertyName("timestamp")] + long Timestamp, + [property:JsonPropertyName("response")] + string? Response, + [property:JsonPropertyName("reason")] + string? Reason, + [property:JsonPropertyName("response_code")] + int? ResponseCode) { + + public WebhookEventType Type => Enum.Parse(EventTypeString.Replace("_", ""), true); + public DateTimeOffset EventDateTime => DateTimeOffset.FromUnixTimeMilliseconds(Timestamp); +} + +public record Webhook { + [JsonPropertyName("events")] + [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] + public ICollection Events { get; } = []; + + public override string ToString() { + return $"Webhook {{ Events = [{string.Join(", ", Events)}] }}"; + } +} \ No newline at end of file diff --git a/Wave/Program.cs b/Wave/Program.cs index 69bf405..8f50722 100644 --- a/Wave/Program.cs +++ b/Wave/Program.cs @@ -101,6 +101,9 @@ }).AddApiKeyInHeader(ApiKeyDefaults.AuthenticationScheme, options => { options.KeyName = "X-API-KEY"; options.Realm = "Wave API"; + }).AddApiKeyInRouteValues("ApiKeyInRoute", options => { + options.KeyName = "apiKey"; + options.Realm = "Wave API"; }) .AddIdentityCookies(); if (builder.Configuration.GetSection("Oidc").Get() is {} oidc && !string.IsNullOrWhiteSpace(oidc.Authority)) { diff --git a/Wave/Services/ApiKeyProvider.cs b/Wave/Services/ApiKeyProvider.cs index b7c49a8..c1f090e 100644 --- a/Wave/Services/ApiKeyProvider.cs +++ b/Wave/Services/ApiKeyProvider.cs @@ -13,7 +13,10 @@ private record ActualApiKey(string Key, string OwnerName, IReadOnlyCollection ProvideAsync(string key) { try { - byte[] data = Convert.FromBase64String(key); + string unescapedKey = key; + if (unescapedKey.Contains('%')) unescapedKey = Uri.UnescapeDataString(key); + + byte[] data = Convert.FromBase64String(unescapedKey); string hashedKey = Convert.ToBase64String(SHA256.HashData(data)); var apiKey = await context.Set().Include(a => a.ApiClaims).SingleOrDefaultAsync(k => k.Key == hashedKey);