From 55ebbba1a3c0c244c8bd4e995869b1c44a8bbea4 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Thu, 9 Jul 2026 15:00:26 -0300 Subject: [PATCH] Add remaining missing webhook email data fields (#105) Completes EmailEventData parity with the Resend webhook payload (verified against public-api and the OpenAPI spec): - open (EmailOpenData: ipAddress, timestamp, userAgent) for email.opened - headers (List), present on all email events - received_for (EmailAddressList) for inbound email.received - suppressed.reason and suppressed.diagnosticCode Also corrects EmailEventAttachment to match the API: id is a string (not Guid; inbound attachment ids are not UUIDs), and filename, content_disposition and content_id are nullable. --- src/Resend.Webhooks/EmailEventAttachment.cs | 17 ++- src/Resend.Webhooks/EmailEventData.cs | 56 +++++++- src/Resend.Webhooks/EmailEventHeader.cs | 17 +++ .../WebhookDataFieldsTests.cs | 129 ++++++++++++++++++ 4 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 src/Resend.Webhooks/EmailEventHeader.cs create mode 100644 tests/Resend.Webhooks.Tests/WebhookDataFieldsTests.cs diff --git a/src/Resend.Webhooks/EmailEventAttachment.cs b/src/Resend.Webhooks/EmailEventAttachment.cs index 0fffb9f..956bbb3 100644 --- a/src/Resend.Webhooks/EmailEventAttachment.cs +++ b/src/Resend.Webhooks/EmailEventAttachment.cs @@ -7,21 +7,24 @@ public class EmailEventAttachment { /// [JsonPropertyName( "id" )] - public Guid Id { get; set; } - - /// - [JsonPropertyName( "filename" )] - public string Filename { get; set; } = default!; + public string Id { get; set; } = default!; /// [JsonPropertyName( "content_type" )] public string ContentType { get; set; } = default!; + /// + [JsonPropertyName( "filename" )] + [JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )] + public string? Filename { get; set; } + /// [JsonPropertyName( "content_disposition" )] - public string ContentDisposition { get; set; } = default!; + [JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )] + public string? ContentDisposition { get; set; } /// [JsonPropertyName( "content_id" )] - public string ContentId { get; set; } = default!; + [JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )] + public string? ContentId { get; set; } } diff --git a/src/Resend.Webhooks/EmailEventData.cs b/src/Resend.Webhooks/EmailEventData.cs index f03b7b3..8da3946 100644 --- a/src/Resend.Webhooks/EmailEventData.cs +++ b/src/Resend.Webhooks/EmailEventData.cs @@ -45,10 +45,25 @@ public class EmailEventData : IWebhookData [JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )] public EmailAddressList? Cc { get; set; } = default!; + /// + /// + /// Only set for , otherwise is null. + /// + [JsonPropertyName( "received_for" )] + [JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )] + public EmailAddressList? ReceivedFor { get; set; } + /// [JsonPropertyName( "subject" )] public string Subject { get; set; } = default!; + /// + /// Custom headers included on the email. + /// + [JsonPropertyName( "headers" )] + [JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )] + public List? Headers { get; set; } + /// [JsonPropertyName( "broadcast_id" )] [JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )] @@ -80,6 +95,14 @@ public class EmailEventData : IWebhookData [JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )] public EmailClickData? Click { get; set; } + /// + /// + /// Only set for , otherwise is null. + /// + [JsonPropertyName( "open" )] + [JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )] + public EmailOpenData? Open { get; set; } + /// /// /// Only set for , otherwise is null. @@ -152,6 +175,24 @@ public class EmailClickData } +/// +public class EmailOpenData +{ + /// + [JsonPropertyName( "ipAddress" )] + public string IpAddress { get; set; } = default!; + + /// + [JsonPropertyName( "timestamp" )] + [JsonConverter( typeof( JsonUtcDateTimeConverter ) )] + public DateTime MomentOpened { get; set; } + + /// + [JsonPropertyName( "userAgent" )] + public string UserAgent { get; set; } = default!; +} + + /// public class EmailFailedData { @@ -169,7 +210,18 @@ public class EmailSuppressedData public string Message { get; set; } = default!; /// - /// TODO: What are the possible values? + /// One of "Suppressed" or "OnAccountSuppressionList". [JsonPropertyName( "type" )] - public string Type { get; set; } = default!; + public string Type { get; set; } = default!; + + /// + /// One of "previous_bounce" or "previous_complaint". + [JsonPropertyName( "reason" )] + [JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )] + public string? Reason { get; set; } + + /// + [JsonPropertyName( "diagnosticCode" )] + [JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingNull )] + public List? DiagnosticCode { get; set; } } \ No newline at end of file diff --git a/src/Resend.Webhooks/EmailEventHeader.cs b/src/Resend.Webhooks/EmailEventHeader.cs new file mode 100644 index 0000000..689a3d8 --- /dev/null +++ b/src/Resend.Webhooks/EmailEventHeader.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Resend.Webhooks; + +/// +/// Custom header included on an email event. +/// +public class EmailEventHeader +{ + /// + [JsonPropertyName( "name" )] + public string Name { get; set; } = default!; + + /// + [JsonPropertyName( "value" )] + public string Value { get; set; } = default!; +} diff --git a/tests/Resend.Webhooks.Tests/WebhookDataFieldsTests.cs b/tests/Resend.Webhooks.Tests/WebhookDataFieldsTests.cs new file mode 100644 index 0000000..3170515 --- /dev/null +++ b/tests/Resend.Webhooks.Tests/WebhookDataFieldsTests.cs @@ -0,0 +1,129 @@ +using System.Text.Json; + +namespace Resend.Webhooks.Tests; + +/// +public class WebhookDataFieldsTests +{ + /// + [Fact] + public void EmailOpenedDeserializesOpenAndHeaders() + { + var json = """ + { + "type": "email.opened", + "created_at": "2024-11-22T23:41:12.126Z", + "data": { + "created_at": "2024-11-22T23:41:11.894Z", + "email_id": "56761188-7520-42d8-8898-ff6fc54ce618", + "from": "onboarding@resend.dev", + "to": [ "delivered@resend.dev" ], + "subject": "Sending this example", + "headers": [ { "name": "X-Entity-Ref-ID", "value": "abc-123" } ], + "open": { "ipAddress": "1.2.3.4", "timestamp": "2024-11-22T23:41:12.126Z", "userAgent": "Mozilla/5.0" } + } + } + """; + + var evt = JsonSerializer.Deserialize( json ); + + Assert.NotNull( evt ); + Assert.Equal( WebhookEventType.EmailOpened, evt.EventType ); + + var data = evt.DataAs(); + + Assert.NotNull( data.Open ); + Assert.Equal( "1.2.3.4", data.Open!.IpAddress ); + Assert.Equal( "Mozilla/5.0", data.Open.UserAgent ); + + Assert.NotNull( data.Headers ); + Assert.Single( data.Headers ); + Assert.Equal( "X-Entity-Ref-ID", data.Headers![ 0 ].Name ); + Assert.Equal( "abc-123", data.Headers[ 0 ].Value ); + } + + + /// + [Fact] + public void EmailReceivedDeserializesInboundFields() + { + var json = """ + { + "type": "email.received", + "created_at": "2024-11-22T23:41:12.126Z", + "data": { + "created_at": "2024-11-22T23:41:11.894Z", + "email_id": "56761188-7520-42d8-8898-ff6fc54ce618", + "from": "sender@example.com", + "to": [ "inbox@example.com" ], + "subject": "Inbound", + "message_id": "", + "bcc": [], + "cc": [ "copy@example.com" ], + "received_for": [ "inbox@example.com" ], + "attachments": [ { + "id": "att_123", + "content_type": "image/png", + "content_disposition": "inline", + "filename": "avatar.png", + "content_id": "img001" + } ] + } + } + """; + + var evt = JsonSerializer.Deserialize( json ); + + Assert.NotNull( evt ); + Assert.Equal( WebhookEventType.EmailReceived, evt.EventType ); + + var data = evt.DataAs(); + + Assert.Equal( "", data.MessageId ); + Assert.NotNull( data.ReceivedFor ); + Assert.Single( data.ReceivedFor! ); + Assert.NotNull( data.Attachments ); + Assert.Single( data.Attachments! ); + Assert.Equal( "att_123", data.Attachments![ 0 ].Id ); + Assert.Equal( "avatar.png", data.Attachments[ 0 ].Filename ); + } + + + /// + [Fact] + public void EmailSuppressedDeserializesAllFields() + { + var json = """ + { + "type": "email.suppressed", + "created_at": "2024-11-22T23:41:12.126Z", + "data": { + "created_at": "2024-11-22T23:41:11.894Z", + "email_id": "56761188-7520-42d8-8898-ff6fc54ce618", + "from": "sender@example.com", + "to": [ "suppressed@example.com" ], + "subject": "Suppressed", + "suppressed": { + "message": "recipient is on the account suppression list", + "type": "OnAccountSuppressionList", + "reason": "previous_bounce", + "diagnosticCode": [ "smtp; 550 5.1.1 user unknown" ] + } + } + } + """; + + var evt = JsonSerializer.Deserialize( json ); + + Assert.NotNull( evt ); + Assert.Equal( WebhookEventType.EmailSuppressed, evt.EventType ); + + var data = evt.DataAs(); + + Assert.NotNull( data.Suppressed ); + Assert.Equal( "OnAccountSuppressionList", data.Suppressed!.Type ); + Assert.Equal( "previous_bounce", data.Suppressed.Reason ); + Assert.NotNull( data.Suppressed.DiagnosticCode ); + Assert.Single( data.Suppressed.DiagnosticCode! ); + } +}