listmonk/internal/bounce/webhooks/sendgrid.go
Kailash Nadh 5fc28a733c Add support for variable bounce processing actions.
- Add support for `complaint` to the SES bounce processor.
- Add support for `hard/soft` to Sendgrid bounce processor.
- Add new bounce actions `None` and `Unsubscribe`.
- Add per type (`soft/hard/complaint`) bounce rule configuration to
  admin settings UI.
- Refactor Cypress bounce tests.
2023-04-11 11:33:40 +05:30

116 lines
2.7 KiB
Go

package webhooks
import (
"crypto/ecdsa"
"crypto/sha256"
"crypto/x509"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"strings"
"time"
"github.com/knadh/listmonk/models"
)
type sendgridNotif struct {
Email string `json:"email"`
Timestamp int64 `json:"timestamp"`
Event string `json:"event"`
BounceClassification string `json:"bounce_classification"`
// SendGrid flattens all X-headers and adds them to the bounce
// event notification.
CampaignUUID string `json:"XListmonkCampaign"`
}
// Sendgrid handles Sendgrid/SNS webhook notifications including confirming SNS topic subscription
// requests and bounce notifications.
type Sendgrid struct {
pubKey *ecdsa.PublicKey
}
// NewSendgrid returns a new Sendgrid instance.
func NewSendgrid(key string) (*Sendgrid, error) {
// Get the certificate from the key.
sigB, err := base64.StdEncoding.DecodeString(key)
if err != nil {
return nil, err
}
pubKey, err := x509.ParsePKIXPublicKey(sigB)
if err != nil {
return nil, err
}
return &Sendgrid{pubKey: pubKey.(*ecdsa.PublicKey)}, nil
}
// ProcessBounce processes Sendgrid bounce notifications and returns one or more Bounce objects.
func (s *Sendgrid) ProcessBounce(sig, timestamp string, b []byte) ([]models.Bounce, error) {
if err := s.verifyNotif(sig, timestamp, b); err != nil {
return nil, err
}
var notifs []sendgridNotif
if err := json.Unmarshal(b, &notifs); err != nil {
return nil, fmt.Errorf("error unmarshalling Sendgrid notification: %v", err)
}
out := make([]models.Bounce, 0, len(notifs))
for _, n := range notifs {
if n.Event != "bounce" {
continue
}
typ := models.BounceTypeHard
if n.BounceClassification == "technical" || n.BounceClassification == "content" {
typ = models.BounceTypeSoft
}
tstamp := time.Unix(n.Timestamp, 0)
bn := models.Bounce{
CampaignUUID: n.CampaignUUID,
Email: strings.ToLower(n.Email),
Type: typ,
Meta: json.RawMessage(b),
Source: "sendgrid",
CreatedAt: tstamp,
}
out = append(out, bn)
}
return out, nil
}
// verifyNotif verifies the signature on a notification payload.
func (s *Sendgrid) verifyNotif(sig, timestamp string, b []byte) error {
sigB, err := base64.StdEncoding.DecodeString(sig)
if err != nil {
return err
}
ecdsaSig := struct {
R *big.Int
S *big.Int
}{}
if _, err := asn1.Unmarshal(sigB, &ecdsaSig); err != nil {
return fmt.Errorf("error asn1 unmarshal of signature: %v", err)
}
h := sha256.New()
h.Write([]byte(timestamp))
h.Write(b)
hash := h.Sum(nil)
if !ecdsa.Verify(s.pubKey, hash, ecdsaSig.R, ecdsaSig.S) {
return errors.New("invalid signature")
}
return nil
}