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);