147 lines
3.6 KiB
Go
147 lines
3.6 KiB
Go
|
|
package webhooks
|
||
|
|
|
||
|
|
import (
|
||
|
|
"crypto/hmac"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/hex"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"math"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"source.offmarket.win/aleagle/eaglecast/models"
|
||
|
|
)
|
||
|
|
|
||
|
|
type lettermintNotif struct {
|
||
|
|
ID string `json:"id"`
|
||
|
|
Event string `json:"event"`
|
||
|
|
Timestamp string `json:"timestamp"`
|
||
|
|
Data struct {
|
||
|
|
MessageID string `json:"message_id"`
|
||
|
|
Subject string `json:"subject"`
|
||
|
|
Recipient string `json:"recipient"`
|
||
|
|
Response *struct {
|
||
|
|
StatusCode int `json:"status_code"`
|
||
|
|
EnhancedStatus string `json:"enhanced_status_code"`
|
||
|
|
Content string `json:"content"`
|
||
|
|
} `json:"response"`
|
||
|
|
Metadata json.RawMessage `json:"metadata"`
|
||
|
|
Tag string `json:"tag"`
|
||
|
|
} `json:"data"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// Lettermint handles bounce webhook notifications from Lettermint.
|
||
|
|
type Lettermint struct {
|
||
|
|
hmacKey []byte
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewLettermint returns a new Lettermint webhook handler.
|
||
|
|
func NewLettermint(key []byte) *Lettermint {
|
||
|
|
return &Lettermint{hmacKey: key}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ProcessBounce processes an incoming Lettermint webhook payload and returns a bounce object.
|
||
|
|
func (l *Lettermint) ProcessBounce(sig string, body []byte) ([]models.Bounce, error) {
|
||
|
|
if len(l.hmacKey) == 0 {
|
||
|
|
return nil, fmt.Errorf("webhook key is not configured")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parse the signature header: t={timestamp},v1={hex_signature}.
|
||
|
|
ts, sigHex, err := parseLettermintSignature(sig)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verify timestamp tolerance (300 seconds).
|
||
|
|
if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
|
||
|
|
return nil, fmt.Errorf("signature timestamp expired")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Decode the hex signature from the header.
|
||
|
|
sigB, err := hex.DecodeString(strings.TrimSpace(sigHex))
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("invalid signature encoding: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Compute HMAC-SHA256 of "{timestamp}.{body}" and compare.
|
||
|
|
mac := hmac.New(sha256.New, l.hmacKey)
|
||
|
|
mac.Write([]byte(fmt.Sprintf("%d.%s", ts, body)))
|
||
|
|
|
||
|
|
if !hmac.Equal(mac.Sum(nil), sigB) {
|
||
|
|
return nil, fmt.Errorf("invalid signature")
|
||
|
|
}
|
||
|
|
|
||
|
|
var n lettermintNotif
|
||
|
|
if err := json.Unmarshal(body, &n); err != nil {
|
||
|
|
return nil, fmt.Errorf("error unmarshalling Lettermint notification: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Map event to bounce type.
|
||
|
|
var typ string
|
||
|
|
switch n.Event {
|
||
|
|
case "message.hard_bounced":
|
||
|
|
typ = models.BounceTypeHard
|
||
|
|
case "message.soft_bounced":
|
||
|
|
typ = models.BounceTypeSoft
|
||
|
|
case "message.spam_complaint":
|
||
|
|
typ = models.BounceTypeComplaint
|
||
|
|
default:
|
||
|
|
// Ignore irrelevant events (e.g. webhook.test).
|
||
|
|
return nil, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
campUUID := ""
|
||
|
|
if len(n.Data.Metadata) > 0 {
|
||
|
|
var meta map[string]string
|
||
|
|
if err := json.Unmarshal(n.Data.Metadata, &meta); err == nil {
|
||
|
|
if v, ok := meta["X-EagleCast-Campaign"]; ok {
|
||
|
|
campUUID = v
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
t, _ := time.Parse(time.RFC3339, n.Timestamp)
|
||
|
|
if t.IsZero() {
|
||
|
|
t = time.Now()
|
||
|
|
}
|
||
|
|
|
||
|
|
return []models.Bounce{{
|
||
|
|
Email: strings.ToLower(n.Data.Recipient),
|
||
|
|
CampaignUUID: campUUID,
|
||
|
|
Type: typ,
|
||
|
|
Source: "lettermint",
|
||
|
|
Meta: json.RawMessage(body),
|
||
|
|
CreatedAt: t,
|
||
|
|
}}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// parseLettermintSignature parses a signature header of the form "t={timestamp},v1={hex}".
|
||
|
|
func parseLettermintSignature(sig string) (int64, string, error) {
|
||
|
|
var (
|
||
|
|
ts int64
|
||
|
|
hash string
|
||
|
|
)
|
||
|
|
|
||
|
|
for _, part := range strings.Split(sig, ",") {
|
||
|
|
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
||
|
|
if len(kv) != 2 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
switch kv[0] {
|
||
|
|
case "t":
|
||
|
|
if _, err := fmt.Sscanf(kv[1], "%d", &ts); err != nil {
|
||
|
|
return 0, "", fmt.Errorf("invalid timestamp in signature: %v", err)
|
||
|
|
}
|
||
|
|
case "v1":
|
||
|
|
hash = kv[1]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if ts == 0 || hash == "" {
|
||
|
|
return 0, "", fmt.Errorf("invalid signature format")
|
||
|
|
}
|
||
|
|
|
||
|
|
return ts, hash, nil
|
||
|
|
}
|