5fc28a733c
- 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.
115 lines
2.7 KiB
Go
115 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, ¬ifs); 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
|
|
}
|