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
+241
View File
@@ -0,0 +1,241 @@
package webhooks
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"source.offmarket.win/aleagle/eaglecast/models"
)
var reSMTPStatus = regexp.MustCompile(`\b([245]\.\d\.\d)\b`)
type azureEvent struct {
EventType string `json:"eventType"`
Data azureEventData `json:"data"`
RawData json.RawMessage `json:"-"`
}
type azureEventData struct {
Recipient string `json:"recipient"`
MessageID string `json:"messageId"`
InternetMessageID string `json:"internetMessageId"`
Status string `json:"status"`
DeliveryStatusDetails azureDeliveryStatus `json:"deliveryStatusDetails"`
DeliveryAttemptTimestamp *time.Time `json:"deliveryAttemptTimestamp"`
DeliveryAttemptTimeStamp *time.Time `json:"deliveryAttemptTimeStamp"`
AdditionalData map[string]json.RawMessage `json:"-"`
}
type azureDeliveryStatus struct {
StatusMessage string `json:"statusMessage"`
}
// Azure handles webhook notifications from Azure Communication Services through Event Grid.
type Azure struct {
sharedSecret string
sharedSecretHeader string
}
// NewAzure returns a new Azure webhook handler.
func NewAzure(sharedSecret, sharedSecretHeader string) *Azure {
return &Azure{
sharedSecret: strings.TrimSpace(sharedSecret),
sharedSecretHeader: strings.TrimSpace(sharedSecretHeader),
}
}
// ProcessSubscription processes Event Grid subscription validation requests and
// returns the response payload that should be written to HTTP response body.
func (a *Azure) ProcessSubscription(b []byte) (json.RawMessage, error) {
events, err := parseAzureEvents(b)
if err != nil {
return nil, err
}
if len(events) == 0 {
return nil, errors.New("empty event payload")
}
// Validation code arrives in the first event for subscription validation flow.
var payload map[string]json.RawMessage
if err := json.Unmarshal(events[0].RawData, &payload); err != nil {
return nil, fmt.Errorf("error reading validation payload: %v", err)
}
rawData, ok := payload["data"]
if !ok {
return nil, errors.New("missing event data")
}
var data map[string]string
if err := json.Unmarshal(rawData, &data); err != nil {
return nil, fmt.Errorf("error reading validation data: %v", err)
}
code := strings.TrimSpace(data["validationCode"])
if code == "" {
return nil, errors.New("missing validationCode in subscription payload")
}
res, _ := json.Marshal(map[string]string{
"validationResponse": code,
})
return json.RawMessage(res), nil
}
// ProcessBounce parses Azure Event Grid email delivery events and returns bounce entries.
func (a *Azure) ProcessBounce(req *http.Request, b []byte) ([]models.Bounce, error) {
if err := a.verifyAuth(req); err != nil {
return nil, err
}
events, err := parseAzureEvents(b)
if err != nil {
return nil, err
}
out := make([]models.Bounce, 0, len(events))
for _, ev := range events {
// Ignore non-delivery report events.
if ev.EventType != "Microsoft.Communication.EmailDeliveryReportReceived" {
continue
}
typ, ok := mapAzureStatus(ev.Data.Status, ev.Data.DeliveryStatusDetails.StatusMessage)
if !ok {
continue
}
email := strings.ToLower(strings.TrimSpace(ev.Data.Recipient))
if email == "" {
continue
}
tstamp := ev.Data.DeliveryAttemptTimestamp
if tstamp == nil {
tstamp = ev.Data.DeliveryAttemptTimeStamp
}
createdAt := time.Now()
if tstamp != nil && !tstamp.IsZero() {
createdAt = *tstamp
}
out = append(out, models.Bounce{
Email: email,
Type: typ,
Meta: json.RawMessage(ev.RawData),
Source: "azure",
CreatedAt: createdAt,
})
}
return out, nil
}
func (a *Azure) verifyAuth(req *http.Request) error {
const (
defaultSecretHeader = "X-EagleCast-Webhook-Secret"
querySecretParam = "code"
)
// If no local credential is configured, allow webhook payloads.
if a.sharedSecret == "" {
return nil
}
if req == nil {
return errors.New("missing azure event grid request context")
}
querySecret := strings.TrimSpace(req.URL.Query().Get(querySecretParam))
if secretsEqual(a.sharedSecret, querySecret) {
return nil
}
headerName := a.sharedSecretHeader
if headerName == "" {
headerName = defaultSecretHeader
}
headerSecret := strings.TrimSpace(req.Header.Get(headerName))
if secretsEqual(a.sharedSecret, headerSecret) {
return nil
}
return errors.New("invalid azure event grid shared secret")
}
func secretsEqual(expected, given string) bool {
if expected == "" || given == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(expected), []byte(given)) == 1
}
func parseAzureEvents(b []byte) ([]azureEvent, error) {
var raws []json.RawMessage
if err := json.Unmarshal(b, &raws); err != nil {
return nil, fmt.Errorf("error unmarshalling azure notification array: %v", err)
}
events := make([]azureEvent, 0, len(raws))
for _, raw := range raws {
ev := azureEvent{RawData: raw}
if err := json.Unmarshal(raw, &ev); err != nil {
return nil, fmt.Errorf("error unmarshalling azure event: %v", err)
}
events = append(events, ev)
}
return events, nil
}
func mapAzureStatus(status, details string) (string, bool) {
s := strings.ToLower(strings.TrimSpace(status))
d := strings.ToLower(strings.TrimSpace(details))
switch s {
case "bounced", "suppressed":
return models.BounceTypeHard, true
case "failed":
// Infer severity from SMTP-enhanced status if available.
if m := reSMTPStatus.FindStringSubmatch(d); len(m) > 1 {
if strings.HasPrefix(m[1], "5.") {
return models.BounceTypeHard, true
}
if strings.HasPrefix(m[1], "4.") {
return models.BounceTypeSoft, true
}
}
if strings.Contains(d, "mailbox full") ||
strings.Contains(d, "temporar") ||
strings.Contains(d, "timeout") ||
strings.Contains(d, "rate limit") ||
strings.Contains(d, "throttle") {
return models.BounceTypeSoft, true
}
if strings.Contains(d, "invalid") ||
strings.Contains(d, "does not exist") ||
strings.Contains(d, "unknown user") ||
strings.Contains(d, "domain") {
return models.BounceTypeHard, true
}
return models.BounceTypeSoft, true
case "filteredspam", "quarantined":
if strings.Contains(d, "spam complaint") ||
strings.Contains(d, "complaint") ||
strings.Contains(d, "abuse") {
return models.BounceTypeComplaint, true
}
return models.BounceTypeSoft, true
default:
return "", false
}
}
+99
View File
@@ -0,0 +1,99 @@
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"source.offmarket.win/aleagle/eaglecast/models"
)
type BounceDetails struct {
Action string `json:"action"`
Message string `json:"message"`
Category string `json:"category"`
Code int `json:"code"`
Status any `json:"status"`
Line int `json:"line"`
}
type forwardemailNotif struct {
EmailID string `json:"email_id"`
ListID string `json:"list_id"`
ListUnsubscribe string `json:"list_unsubscribe"`
FeedbackID string `json:"feedback_id"`
Recipient string `json:"recipient"`
Message string `json:"message"`
Response string `json:"response"`
ResponseCode int `json:"response_code"`
TruthSource string `json:"truth_source"`
Headers map[string]string `json:"headers"`
Bounce BounceDetails `json:"bounce"`
BouncedAt time.Time `json:"bounced_at"`
}
// Forwardemail handles webhook notifications (mainly bounce notifications).
type Forwardemail struct {
hmacKey []byte
}
func NewForwardemail(key []byte) *Forwardemail {
return &Forwardemail{hmacKey: key}
}
func (p *Forwardemail) ProcessBounce(sigHex string, body []byte) ([]models.Bounce, error) {
if len(p.hmacKey) == 0 {
return nil, errors.New("webhook key is not configured")
}
// Decode the hex-encoded signature from the webhook
sig, err := hex.DecodeString(sigHex)
if err != nil {
return nil, fmt.Errorf("invalid signature encoding: %v", err)
}
// Generate HMAC using the request body and secret key
mac := hmac.New(sha256.New, p.hmacKey)
mac.Write(body)
expectedSignature := mac.Sum(nil)
// Compare the generated signature with the provided signature
if !hmac.Equal(expectedSignature, sig) {
return nil, errors.New("invalid signature")
}
// Parse the JSON payload
var n forwardemailNotif
if err := json.Unmarshal(body, &n); err != nil {
return nil, fmt.Errorf("error unmarshalling Forwardemail notification: %v", err)
}
// Categorize the bounce type
typ := models.BounceTypeSoft
hardBounceCategories := []string{"block", "recipient", "virus", "spam"}
for _, category := range hardBounceCategories {
if n.Bounce.Category == category {
typ = models.BounceTypeHard
break
}
}
campUUID := ""
if v, ok := n.Headers["X-EagleCast-Campaign"]; ok {
campUUID = v
}
return []models.Bounce{{
Email: strings.ToLower(n.Recipient),
CampaignUUID: campUUID,
Type: typ,
Source: "forwardemail",
Meta: json.RawMessage(body),
CreatedAt: n.BouncedAt,
}}, nil
}
+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
}
+119
View File
@@ -0,0 +1,119 @@
package webhooks
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"source.offmarket.win/aleagle/eaglecast/models"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
type postmarkNotif struct {
RecordType string `json:"RecordType"`
MessageStream string `json:"MessageStream"`
ID int `json:"ID"`
Type string `json:"Type"`
TypeCode int `json:"TypeCode"`
Name string `json:"Name"`
Tag string `json:"Tag"`
MessageID string `json:"MessageID"`
Metadata map[string]string `json:"Metadata"`
ServerID int `json:"ServerID"`
Description string `json:"Description"`
Details string `json:"Details"`
Email string `json:"Email"`
From string `json:"From"`
BouncedAt time.Time `json:"BouncedAt"` // "2019-11-05T16:33:54.9070259Z"
DumpAvailable bool `json:"DumpAvailable"`
Inactive bool `json:"Inactive"`
CanActivate bool `json:"CanActivate"`
Subject string `json:"Subject"`
Content string `json:"Content"`
}
// Postmark handles webhook notifications (mainly bounce notifications).
type Postmark struct {
authHandler echo.HandlerFunc
}
func NewPostmark(username, password string) *Postmark {
return &Postmark{
authHandler: middleware.BasicAuth(makePostmarkAuthHandler(username, password))(func(c echo.Context) error {
return nil
}),
}
}
// ProcessBounce processes Postmark bounce notifications and returns one object.
func (p *Postmark) ProcessBounce(b []byte, c echo.Context) ([]models.Bounce, error) {
// Do basicauth.
if err := p.authHandler(c); err != nil {
return nil, err
}
var n postmarkNotif
if err := json.Unmarshal(b, &n); err != nil {
return nil, fmt.Errorf("error unmarshalling postmark notification: %v", err)
}
// Ignore irrelevant messages.
if n.RecordType != "Bounce" && n.RecordType != "SpamComplaint" {
return nil, nil
}
supportedBounceType := true
typ := models.BounceTypeHard
switch n.Type {
case "HardBounce", "BadEmailAddress", "ManuallyDeactivated":
typ = models.BounceTypeHard
case "SoftBounce", "Transient", "DnsError", "SpamNotification", "VirusNotification", "DMARCPolicy":
typ = models.BounceTypeSoft
case "SpamComplaint":
typ = models.BounceTypeComplaint
default:
supportedBounceType = false
}
if !supportedBounceType {
return nil, fmt.Errorf("unsupported bounce type: %v", n.Type)
}
// Look for the campaign ID in headers.
campUUID := ""
if v, ok := n.Metadata["X-EagleCast-Campaign"]; ok {
campUUID = v
}
return []models.Bounce{{
Email: strings.ToLower(n.Email),
CampaignUUID: campUUID,
Type: typ,
Source: "postmark",
Meta: json.RawMessage(b),
CreatedAt: n.BouncedAt,
}}, nil
}
func makePostmarkAuthHandler(cfgUser, cfgPassword string) func(username, password string, c echo.Context) (bool, error) {
var (
u = []byte(cfgUser)
p = []byte(cfgPassword)
)
return func(username, password string, c echo.Context) (bool, error) {
if len(u) == 0 || len(p) == 0 {
return false, errors.New("webhook credentials are not configured")
}
if subtle.ConstantTimeCompare([]byte(username), u) == 1 && subtle.ConstantTimeCompare([]byte(password), p) == 1 {
return true, nil
}
return false, nil
}
}
+115
View File
@@ -0,0 +1,115 @@
package webhooks
import (
"crypto/ecdsa"
"crypto/sha256"
"crypto/x509"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"strings"
"time"
"source.offmarket.win/aleagle/eaglecast/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:"XEagleCastCampaign"`
}
// 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
}
+283
View File
@@ -0,0 +1,283 @@
package webhooks
import (
"bytes"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"source.offmarket.win/aleagle/eaglecast/models"
)
// AWS signature/validation logic borrowed from @cavnit's contrib:
// https://gist.github.com/cavnit/f4d63ba52b3aa05406c07dcbca2ca6cf
// https://sns.ap-southeast-1.amazonaws.com/SimpleNotificationService-010a507c1833636cd94bdb98bd93083a.pem
var sesRegCertURL = regexp.MustCompile(`(?i)^https://sns\.[a-z0-9\-]+\.amazonaws\.com(\.cn)?/SimpleNotificationService\-[a-z0-9]+\.pem$`)
// sesNotif is an individual notification wrapper posted by SNS.
type sesNotif struct {
// Message may be a plaintext message or a stringified JSON payload based on the message type.
// Four SES messages, this is the actual payload.
Message string `json:"Message"`
MessageId string `json:"MessageId"`
Signature string `json:"Signature"`
SignatureVersion string `json:"SignatureVersion"`
SigningCertURL string `json:"SigningCertURL"`
Subject string `json:"Subject"`
Timestamp string `json:"Timestamp"`
Token string `json:"Token"`
TopicArn string `json:"TopicArn"`
Type string `json:"Type"`
SubscribeURL string `json:"SubscribeURL"`
UnsubscribeURL string `json:"UnsubscribeURL"`
}
type sesTimestamp time.Time
type sesMail struct {
EventType string `json:"eventType"`
NotifType string `json:"notificationType"`
Bounce struct {
BounceType string `json:"bounceType"`
BouncedRecipients []struct {
Status string `json:"status"`
} `json:"bouncedRecipients"`
} `json:"bounce"`
Mail struct {
Timestamp sesTimestamp `json:"timestamp"`
HeadersTruncated bool `json:"headersTruncated"`
Destination []string `json:"destination"`
Headers []map[string]string `json:"headers"`
} `json:"mail"`
}
// SES handles SES/SNS webhook notifications including confirming SNS topic subscription
// requests and bounce notifications.
type SES struct {
mu sync.RWMutex
certs map[string]*x509.Certificate
}
// NewSES returns a new SES instance.
func NewSES() *SES {
return &SES{
certs: make(map[string]*x509.Certificate),
}
}
// ProcessSubscription processes an SNS topic subscribe / unsubscribe notification
// by parsing and verifying the payload and calling the subscribe / unsubscribe URL.
func (s *SES) ProcessSubscription(b []byte) error {
var n sesNotif
if err := json.Unmarshal(b, &n); err != nil {
return fmt.Errorf("error unmarshalling SNS notification: %v", err)
}
if err := s.verifyNotif(n); err != nil {
return err
}
// Make an HTTP request to the sub/unsub URL.
u := n.SubscribeURL
if n.Type == "UnsubscriptionConfirmation" {
u = n.UnsubscribeURL
}
resp, err := http.Get(u)
if err != nil {
return fmt.Errorf("error requesting subscription URL: %v", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("non 200 response on subscription URL: %v", resp.StatusCode)
}
return nil
}
// ProcessBounce processes an SES bounce notification and returns a Bounce object.
func (s *SES) ProcessBounce(b []byte) (models.Bounce, error) {
var (
bounce models.Bounce
n sesNotif
)
if err := json.Unmarshal(b, &n); err != nil {
return bounce, fmt.Errorf("error unmarshalling SES notification: %v", err)
}
if err := s.verifyNotif(n); err != nil {
return bounce, err
}
var m sesMail
if err := json.Unmarshal([]byte(n.Message), &m); err != nil {
return bounce, fmt.Errorf("error unmarshalling SES notification: %v", err)
}
if (m.EventType != "" && m.EventType != "Bounce") ||
(m.NotifType != "" && (m.NotifType != "Bounce" && m.NotifType != "Complaint")) {
return bounce, errors.New("notification type is not bounce")
}
if len(m.Mail.Destination) == 0 {
return bounce, errors.New("no destination e-mails found in SES notification")
}
typ := models.BounceTypeSoft
if m.Bounce.BounceType == "Permanent" {
typ = models.BounceTypeHard
}
if m.Bounce.BounceType == "Transient" && len(m.Bounce.BouncedRecipients) > 0 {
// "Invalid domain" bounce.
if m.Bounce.BouncedRecipients[0].Status == "5.4.4" {
typ = models.BounceTypeHard
}
}
if m.NotifType == "Complaint" {
typ = models.BounceTypeComplaint
}
// Look for the campaign ID in headers.
campUUID := ""
if !m.Mail.HeadersTruncated {
for _, h := range m.Mail.Headers {
key, ok := h["name"]
if !ok || key != models.EmailHeaderCampaignUUID {
continue
}
campUUID, ok = h["value"]
if !ok {
continue
}
break
}
}
return models.Bounce{
Email: strings.ToLower(m.Mail.Destination[0]),
CampaignUUID: campUUID,
Type: typ,
Source: "ses",
Meta: json.RawMessage(n.Message),
CreatedAt: time.Time(m.Mail.Timestamp),
}, nil
}
func (s *SES) buildSignature(n sesNotif) []byte {
var b bytes.Buffer
b.WriteString("Message" + "\n" + n.Message + "\n")
b.WriteString("MessageId" + "\n" + n.MessageId + "\n")
if n.Subject != "" {
b.WriteString("Subject" + "\n" + n.Subject + "\n")
}
if n.SubscribeURL != "" {
b.WriteString("SubscribeURL" + "\n" + n.SubscribeURL + "\n")
}
b.WriteString("Timestamp" + "\n" + n.Timestamp + "\n")
if n.Token != "" {
b.WriteString("Token" + "\n" + n.Token + "\n")
}
b.WriteString("TopicArn" + "\n" + n.TopicArn + "\n")
b.WriteString("Type" + "\n" + n.Type + "\n")
return b.Bytes()
}
// verifyNotif verifies the signature on a notification payload.
func (s *SES) verifyNotif(n sesNotif) error {
// Get the message signing certificate.
cert, err := s.getCert(n.SigningCertURL)
if err != nil {
return fmt.Errorf("error getting SNS cert: %v", err)
}
sign, err := base64.StdEncoding.DecodeString(n.Signature)
if err != nil {
return err
}
return cert.CheckSignature(x509.SHA1WithRSA, s.buildSignature(n), sign)
}
// getCert takes the SNS certificate URL and fetches it and caches it for the first time,
// and returns the cached cert for subsequent calls.
func (s *SES) getCert(certURL string) (*x509.Certificate, error) {
// Ensure that the cert URL is Amazon's.
u, err := url.Parse(certURL)
if err != nil {
return nil, err
}
if !sesRegCertURL.MatchString(certURL) {
return nil, fmt.Errorf("invalid SNS certificate URL: %v", u.Host)
}
// Return if it's cached.
s.mu.RLock()
c, ok := s.certs[u.Path]
s.mu.RUnlock()
if ok {
return c, nil
}
// Fetch the certificate.
resp, err := http.Get(certURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("invalid SNS certificate URL: %v", u.Host)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
p, _ := pem.Decode(body)
if p == nil {
return nil, errors.New("invalid PEM")
}
cert, err := x509.ParseCertificate(p.Bytes)
// Cache the cert in-memory.
s.mu.Lock()
// Check again if another goroutine already cached it while we were fetching.
if c2, ok := s.certs[u.Path]; ok && c2 != nil {
s.mu.Unlock()
// Return the cached cert and ignore this goroutine's parse error (if any).
return c2, nil
}
// Only cache when parsing succeeded (don't cache nil certs from failures).
if err == nil {
s.certs[u.Path] = cert
}
s.mu.Unlock()
return cert, err
}
func (st *sesTimestamp) UnmarshalJSON(b []byte) error {
t, err := time.Parse("2006-01-02T15:04:05.999999999Z", strings.Trim(string(b), `"`))
if err != nil {
return err
}
*st = sesTimestamp(t)
return nil
}