EagleCast
publish-github-pages / deploy (push) Waiting to run

This commit is contained in:
h202-wq
2026-07-09 10:03:32 -04:00
commit 66d9a033c9
446 changed files with 162542 additions and 0 deletions
+146
View File
@@ -0,0 +1,146 @@
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
}