From 61c421b844eeff071ea8c8dac2541f908ffad436 Mon Sep 17 00:00:00 2001 From: ckdev Date: Sat, 3 Oct 2026 22:34:07 +0000 Subject: [PATCH] fix(postmark): accept numeric ID in webhook payloads; use BouncedAt for complaints Postmark sends "ID" as a JSON number in Bounce and SpamComplaint webhook payloads (e.g. "ID": 692560173), but PostmarkWebhookPayload.ID was a string, so json.Unmarshal failed and the endpoint returned 400. Postmark now verifies webhooks on creation by posting sample events and rejects the webhook on any non-2xx (HTTP 422, ErrorCode 1364), so "Register webhook" fails for Postmark integrations. Real bounce/complaint events are rejected for the same reason. - PostmarkWebhookPayload.ID: string -> json.Number (accepts numbers and string-encoded numbers, so existing payloads keep working). - SpamComplaint: Postmark carries the event time in "BouncedAt"; fall back to it when "ComplainedAt" is absent instead of using time.Now(). - Regression tests with the official Postmark sample payloads. --- internal/domain/email_provider_postmark.go | 17 +++++++------ .../service/inbound_webhook_event_service.go | 7 +++++- .../inbound_webhook_event_service_test.go | 25 +++++++++++++++++++ 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/internal/domain/email_provider_postmark.go b/internal/domain/email_provider_postmark.go index 321663c0f..dafbbbb92 100644 --- a/internal/domain/email_provider_postmark.go +++ b/internal/domain/email_provider_postmark.go @@ -2,6 +2,7 @@ package domain import ( "context" + "encoding/json" "fmt" "github.com/Notifuse/notifuse/pkg/crypto" @@ -9,13 +10,15 @@ import ( // PostmarkWebhookPayload represents the base webhook payload from Postmark type PostmarkWebhookPayload struct { - RecordType string `json:"RecordType"` - MessageStream string `json:"MessageStream"` - ID string `json:"ID"` - MessageID string `json:"MessageID"` - ServerID int `json:"ServerID"` - Metadata map[string]string `json:"Metadata,omitempty"` - Tag string `json:"Tag,omitempty"` + RecordType string `json:"RecordType"` + MessageStream string `json:"MessageStream"` + // ID is a number in Postmark Bounce/SpamComplaint payloads (e.g. 692560173); + // json.Number accepts both numeric and string-encoded values. + ID json.Number `json:"ID"` + MessageID string `json:"MessageID"` + ServerID int `json:"ServerID"` + Metadata map[string]string `json:"Metadata,omitempty"` + Tag string `json:"Tag,omitempty"` // Delivered event specific fields DeliveredFields *PostmarkDeliveredFields `json:"-"` diff --git a/internal/service/inbound_webhook_event_service.go b/internal/service/inbound_webhook_event_service.go index 070a41178..72089401e 100644 --- a/internal/service/inbound_webhook_event_service.go +++ b/internal/service/inbound_webhook_event_service.go @@ -766,7 +766,12 @@ func (s *InboundWebhookEventService) processPostmarkWebhook(integrationID string complaintFeedbackType = typeStr } - if t, ok := jsonData["ComplainedAt"].(string); ok && t != "" { + // Postmark sends the complaint time in "BouncedAt"; keep "ComplainedAt" for compatibility. + complainedAt, _ := jsonData["ComplainedAt"].(string) + if complainedAt == "" { + complainedAt, _ = jsonData["BouncedAt"].(string) + } + if t := complainedAt; t != "" { if parsedTime, err := time.Parse(time.RFC3339, t); err == nil { timestamp = parsedTime } else { diff --git a/internal/service/inbound_webhook_event_service_test.go b/internal/service/inbound_webhook_event_service_test.go index 9e459571e..d927585c0 100644 --- a/internal/service/inbound_webhook_event_service_test.go +++ b/internal/service/inbound_webhook_event_service_test.go @@ -785,6 +785,31 @@ func TestProcessPostmarkWebhook(t *testing.T) { assert.Equal(t, "message1", *events[0].MessageID) }) + // Postmark sends "ID" as a JSON number in Bounce/SpamComplaint payloads + // (https://postmarkapp.com/developer/webhooks/bounce-webhook). Postmark's webhook + // verification sends these payloads and rejects the webhook on any non-2xx. + t.Run("Bounce Event with numeric ID (official Postmark payload)", func(t *testing.T) { + rawPayload := []byte(`{"RecordType":"Bounce","MessageStream":"broadcast","ID":692560173,"Type":"HardBounce","TypeCode":1,"Name":"Hard bounce","MessageID":"883953f4-6105-42a2-a16a-77a8eac79483","ServerID":23,"Email":"test@example.com","BouncedAt":"2026-11-05T16:33:54.9070259Z","DumpAvailable":true,"Inactive":true,"CanActivate":true,"Subject":"Hello"}`) + + events, err := service.processPostmarkWebhook(integrationID, rawPayload) + + require.NoError(t, err) + require.Len(t, events, 1) + assert.Equal(t, domain.EmailEventBounce, events[0].Type) + assert.Equal(t, "test@example.com", events[0].RecipientEmail) + }) + + t.Run("SpamComplaint with numeric ID uses BouncedAt as timestamp", func(t *testing.T) { + rawPayload := []byte(`{"RecordType":"SpamComplaint","MessageStream":"broadcast","ID":692560174,"Type":"SpamComplaint","TypeCode":100001,"Name":"Spam complaint","MessageID":"883953f4-6105-42a2-a16a-77a8eac79483","ServerID":23,"Email":"test@example.com","BouncedAt":"2026-11-05T16:33:54Z","Subject":"Hello"}`) + + events, err := service.processPostmarkWebhook(integrationID, rawPayload) + + require.NoError(t, err) + require.Len(t, events, 1) + assert.Equal(t, domain.EmailEventComplaint, events[0].Type) + assert.Equal(t, time.Date(2026, 11, 5, 16, 33, 54, 0, time.UTC), events[0].Timestamp.UTC()) + }) + t.Run("Bounce Event", func(t *testing.T) { // Create test bounce payload using a map to ensure correct JSON structure rawPayload, err := json.Marshal(map[string]interface{}{