Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions internal/domain/email_provider_postmark.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,23 @@ package domain

import (
"context"
"encoding/json"
"fmt"

"github.com/Notifuse/notifuse/pkg/crypto"
)

// 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:"-"`
Expand Down
7 changes: 6 additions & 1 deletion internal/service/inbound_webhook_event_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions internal/service/inbound_webhook_event_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}{
Expand Down