@@ -0,0 +1,157 @@
|
||||
package bounce
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/bounce/mailbox"
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/bounce/webhooks"
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
)
|
||||
|
||||
// Mailbox represents a POP/IMAP mailbox client that can scan messages and pass
|
||||
// them to a given channel.
|
||||
type Mailbox interface {
|
||||
Scan(limit int, ch chan models.Bounce) error
|
||||
}
|
||||
|
||||
// Opt represents bounce processing options.
|
||||
type Opt struct {
|
||||
MailboxEnabled bool `json:"mailbox_enabled"`
|
||||
MailboxType string `json:"mailbox_type"`
|
||||
Mailbox mailbox.Opt `json:"mailbox"`
|
||||
WebhooksEnabled bool `json:"webhooks_enabled"`
|
||||
SESEnabled bool `json:"ses_enabled"`
|
||||
AzureEnabled bool `json:"azure_enabled"`
|
||||
AzureSharedSecret string `json:"azure_shared_secret"`
|
||||
AzureSharedSecretHeader string `json:"azure_shared_secret_header"`
|
||||
SendgridEnabled bool `json:"sendgrid_enabled"`
|
||||
SendgridKey string `json:"sendgrid_key"`
|
||||
Postmark struct {
|
||||
Enabled bool
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
ForwardEmail struct {
|
||||
Enabled bool
|
||||
Key string
|
||||
}
|
||||
Lettermint struct {
|
||||
Enabled bool
|
||||
Key string
|
||||
}
|
||||
|
||||
RecordBounceCB func(models.Bounce) error
|
||||
}
|
||||
|
||||
// Manager handles e-mail bounces.
|
||||
type Manager struct {
|
||||
queue chan models.Bounce
|
||||
mailbox Mailbox
|
||||
SES *webhooks.SES
|
||||
Azure *webhooks.Azure
|
||||
Sendgrid *webhooks.Sendgrid
|
||||
Postmark *webhooks.Postmark
|
||||
Forwardemail *webhooks.Forwardemail
|
||||
Lettermint *webhooks.Lettermint
|
||||
queries *Queries
|
||||
opt Opt
|
||||
log *log.Logger
|
||||
}
|
||||
|
||||
// Queries contains the queries.
|
||||
type Queries struct {
|
||||
DB *sqlx.DB
|
||||
RecordQuery *sqlx.Stmt
|
||||
}
|
||||
|
||||
// New returns a new instance of the bounce manager.
|
||||
func New(opt Opt, q *Queries, lo *log.Logger) (*Manager, error) {
|
||||
m := &Manager{
|
||||
opt: opt,
|
||||
queries: q,
|
||||
queue: make(chan models.Bounce, 1000),
|
||||
log: lo,
|
||||
}
|
||||
|
||||
// Is there a mailbox?
|
||||
if opt.MailboxEnabled {
|
||||
switch opt.MailboxType {
|
||||
case "pop":
|
||||
m.mailbox = mailbox.NewPOP(opt.Mailbox, lo)
|
||||
default:
|
||||
return nil, errors.New("unknown bounce mailbox type")
|
||||
}
|
||||
}
|
||||
|
||||
if opt.WebhooksEnabled {
|
||||
if opt.SESEnabled {
|
||||
m.SES = webhooks.NewSES()
|
||||
}
|
||||
if opt.AzureEnabled {
|
||||
m.Azure = webhooks.NewAzure(opt.AzureSharedSecret, opt.AzureSharedSecretHeader)
|
||||
}
|
||||
|
||||
if opt.SendgridEnabled {
|
||||
sg, err := webhooks.NewSendgrid(opt.SendgridKey)
|
||||
if err != nil {
|
||||
lo.Printf("error initializing sendgrid webhooks: %v", err)
|
||||
} else {
|
||||
m.Sendgrid = sg
|
||||
}
|
||||
}
|
||||
|
||||
if opt.Postmark.Enabled {
|
||||
m.Postmark = webhooks.NewPostmark(opt.Postmark.Username, opt.Postmark.Password)
|
||||
}
|
||||
|
||||
if opt.ForwardEmail.Enabled {
|
||||
fe := webhooks.NewForwardemail([]byte(opt.ForwardEmail.Key))
|
||||
m.Forwardemail = fe
|
||||
}
|
||||
|
||||
if opt.Lettermint.Enabled {
|
||||
m.Lettermint = webhooks.NewLettermint([]byte(opt.Lettermint.Key))
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Run is a blocking function that listens for bounce events from webhooks and or mailboxes
|
||||
// and executes them on the DB.
|
||||
func (m *Manager) Run() {
|
||||
if m.opt.MailboxEnabled {
|
||||
go m.runMailboxScanner()
|
||||
}
|
||||
|
||||
for b := range m.queue {
|
||||
if b.CreatedAt.IsZero() {
|
||||
b.CreatedAt = time.Now()
|
||||
}
|
||||
|
||||
if err := m.opt.RecordBounceCB(b); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runMailboxScanner runs a blocking loop that scans the mailbox at given intervals.
|
||||
func (m *Manager) runMailboxScanner() {
|
||||
for {
|
||||
m.log.Printf("scanning bounce mailbox %s", m.opt.Mailbox.Host)
|
||||
if err := m.mailbox.Scan(1000, m.queue); err != nil {
|
||||
m.log.Printf("error scanning bounce mailbox: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(m.opt.Mailbox.ScanInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// Record records a new bounce event given the subscriber's email or UUID.
|
||||
func (m *Manager) Record(b models.Bounce) error {
|
||||
m.queue <- b
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package mailbox
|
||||
|
||||
import "time"
|
||||
|
||||
// Opt represents an e-mail POP/IMAP mailbox configuration.
|
||||
type Opt struct {
|
||||
// Host is the server's hostname.
|
||||
Host string `json:"host"`
|
||||
|
||||
// Port is the server port.
|
||||
Port int `json:"port"`
|
||||
|
||||
AuthProtocol string `json:"auth_protocol"`
|
||||
|
||||
// Username is the mail server login username.
|
||||
Username string `json:"username"`
|
||||
|
||||
// Password is the mail server login password.
|
||||
Password string `json:"password"`
|
||||
|
||||
// Folder is the name of the IMAP folder to scan for e-mails.
|
||||
Folder string `json:"folder"`
|
||||
|
||||
// Optional TLS settings.
|
||||
TLSEnabled bool `json:"tls_enabled"`
|
||||
TLSSkipVerify bool `json:"tls_skip_verify"`
|
||||
|
||||
ScanInterval time.Duration `json:"scan_interval"`
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package mailbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-message"
|
||||
_ "github.com/emersion/go-message/charset"
|
||||
"github.com/knadh/go-pop3"
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
)
|
||||
|
||||
// POP represents a POP mailbox.
|
||||
type POP struct {
|
||||
opt Opt
|
||||
client *pop3.Client
|
||||
lo *log.Logger
|
||||
}
|
||||
|
||||
type bounceHeaders struct {
|
||||
Header string
|
||||
Regexp *regexp.Regexp
|
||||
}
|
||||
|
||||
type bounceMeta struct {
|
||||
From string `json:"from"`
|
||||
Subject string `json:"subject"`
|
||||
MessageID string `json:"message_id"`
|
||||
DeliveredTo string `json:"delivered_to"`
|
||||
Received []string `json:"received"`
|
||||
ClassifyReason string `json:"classify_reason"`
|
||||
}
|
||||
|
||||
var (
|
||||
// List of header to look for in the e-mail body, regexp to fall back to if the header is empty.
|
||||
headerLookups = []bounceHeaders{
|
||||
{models.EmailHeaderCampaignUUID, regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderCampaignUUID + `:\s+?)([a-z0-9\-]{36})`)},
|
||||
{models.EmailHeaderSubscriberUUID, regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderSubscriberUUID + `:\s+?)([a-z0-9\-]{36})`)},
|
||||
{models.EmailHeaderDate, regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderDate + `:\s+?)([\w,\,\ ,:,+,-]*(?:\(?:\w*\))?)`)},
|
||||
{models.EmailHeaderFrom, regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderFrom + `:\s+?)(.*)`)},
|
||||
{models.EmailHeaderSubject, regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderSubject + `:\s+?)(.*)`)},
|
||||
{models.EmailHeaderMessageId, regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderMessageId + `:\s+?)(.*)`)},
|
||||
{models.EmailHeaderDeliveredTo, regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderDeliveredTo + `:\s+?)(.*)`)},
|
||||
}
|
||||
|
||||
reHdrReceived = regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderReceived + `:\s+?)(.*)`)
|
||||
|
||||
// SMTP status code (5.x.x or 4.x.x) to classify hard/soft bounces.
|
||||
reSMTPStatus = regexp.MustCompile(`(?m)(?i)^(?:Status:\s*)?(?:\d{3}\s+)?([45]\.\d+\.\d+)`)
|
||||
|
||||
// List of (conventional) strings to guess hard bounces.
|
||||
reHardBounce = regexp.MustCompile(`(?i)(NXDOMAIN|user unknown|address not found|mailbox not found|address.*reject|does not exist|` +
|
||||
`invalid recipient|no such user|recipient.*invalid|undeliverable|permanent.*failure|permanent.*error|` +
|
||||
`bad.*address|unknown.*user|account.*disabled|address.*disabled)`)
|
||||
)
|
||||
|
||||
// NewPOP returns a new instance of the POP mailbox client.
|
||||
func NewPOP(opt Opt, lo *log.Logger) *POP {
|
||||
return &POP{
|
||||
opt: opt,
|
||||
client: pop3.New(pop3.Opt{
|
||||
Host: opt.Host,
|
||||
Port: opt.Port,
|
||||
TLSEnabled: opt.TLSEnabled,
|
||||
TLSSkipVerify: opt.TLSSkipVerify,
|
||||
}),
|
||||
lo: lo,
|
||||
}
|
||||
}
|
||||
|
||||
// Scan scans the mailbox and pushes the downloaded messages into the given channel.
|
||||
// The messages that are downloaded are deleted from the server. If limit > 0,
|
||||
// all messages on the server are downloaded and deleted.
|
||||
func (p *POP) Scan(limit int, ch chan models.Bounce) error {
|
||||
c, err := p.client.NewConn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Quit()
|
||||
|
||||
// Authenticate.
|
||||
if p.opt.AuthProtocol != "none" {
|
||||
if err := c.Auth(p.opt.Username, p.opt.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Get the total number of messages on the server.
|
||||
count, _, err := c.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// No messages.
|
||||
if count == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if limit > 0 && count > limit {
|
||||
count = limit
|
||||
}
|
||||
|
||||
// Download messages.
|
||||
for id := 1; id <= count; id++ {
|
||||
// Retrieve the raw bytes of the message.
|
||||
b, err := c.RetrRaw(id)
|
||||
if err != nil {
|
||||
p.lo.Printf("error retrieving bounce message %d: %v", id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse the message.
|
||||
m, err := message.Read(b)
|
||||
if err != nil {
|
||||
p.lo.Printf("error parsing bounce message %d: %v", id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
h := m
|
||||
|
||||
// If this is a multipart message, find the last part.
|
||||
if mr := m.MultipartReader(); mr != nil {
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
p.lo.Printf("error reading multipart bounce message %d: %v", id, err)
|
||||
continue
|
||||
}
|
||||
h = part
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the "unread portion" pointer of the message buffer.
|
||||
// If you don't do this, you can't read the entire body because the pointer will not point to the beginning.
|
||||
b, _ = c.RetrRaw(id)
|
||||
|
||||
// Lookup headers in the e-mail. If a header isn't found, fall back to regexp lookups.
|
||||
hdr := make(map[string]string, 7)
|
||||
for _, l := range headerLookups {
|
||||
v := h.Header.Get(l.Header)
|
||||
|
||||
// Not in the header. Try regexp.
|
||||
if v == "" {
|
||||
if m := l.Regexp.FindAllSubmatch(b.Bytes(), -1); m != nil {
|
||||
v = string(m[len(m)-1][1])
|
||||
}
|
||||
}
|
||||
|
||||
hdr[l.Header] = strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
// Received is a []string header.
|
||||
msgReceived := h.Header.Map()[models.EmailHeaderReceived]
|
||||
if len(msgReceived) == 0 {
|
||||
if u := reHdrReceived.FindAllSubmatch(b.Bytes(), -1); u != nil {
|
||||
for i := range u {
|
||||
msgReceived = append(msgReceived, string(u[i][1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
date, _ := time.Parse("Mon, 02 Jan 2006 15:04:05 -0700", hdr[models.EmailHeaderDate])
|
||||
if date.IsZero() {
|
||||
date = time.Now()
|
||||
}
|
||||
|
||||
// Classify the bounce type based on message content.
|
||||
bounceType, bounceReason := classifyBounce(b.Bytes())
|
||||
|
||||
// Additional bounce e-mail metadata.
|
||||
meta, _ := json.Marshal(bounceMeta{
|
||||
From: hdr[models.EmailHeaderFrom],
|
||||
Subject: hdr[models.EmailHeaderSubject],
|
||||
MessageID: hdr[models.EmailHeaderMessageId],
|
||||
DeliveredTo: hdr[models.EmailHeaderDeliveredTo],
|
||||
Received: msgReceived,
|
||||
ClassifyReason: bounceReason,
|
||||
})
|
||||
|
||||
select {
|
||||
case ch <- models.Bounce{
|
||||
Type: bounceType,
|
||||
CampaignUUID: hdr[models.EmailHeaderCampaignUUID],
|
||||
SubscriberUUID: hdr[models.EmailHeaderSubscriberUUID],
|
||||
Source: p.opt.Host,
|
||||
CreatedAt: date,
|
||||
Meta: meta,
|
||||
}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the downloaded messages.
|
||||
for id := 1; id <= count; id++ {
|
||||
if err := c.Dele(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifyBounce analyzes the bounce message content and determines if it's a hard or soft bounce.
|
||||
// It checks SMTP status codes, diagnostic headers, and bounce keywords (using string heuristics).
|
||||
// soft is the default preference.
|
||||
// Returns the bounce type and a classification reason containing context about what matched.
|
||||
func classifyBounce(b []byte) (string, string) {
|
||||
if matches := reSMTPStatus.FindAllSubmatch(b, -1); matches != nil {
|
||||
for _, m := range matches {
|
||||
if len(m) >= 2 && len(m[0]) > 1 {
|
||||
// Full status code (e.g., "5.1.1").
|
||||
status := m[1]
|
||||
|
||||
// 5.x.x is hard bounce.
|
||||
if status[0] == '5' {
|
||||
return models.BounceTypeHard, fmt.Sprintf("smtp_status=%s", status)
|
||||
}
|
||||
|
||||
// 4.x.x is soft bounce.
|
||||
if status[0] == '4' {
|
||||
return models.BounceTypeSoft, fmt.Sprintf("smtp_status=%s", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for explicit hard bounce keywords.
|
||||
if match := reHardBounce.FindSubmatch(b); match != nil {
|
||||
return models.BounceTypeHard, fmt.Sprintf("body_match=%s", match[1])
|
||||
}
|
||||
|
||||
return models.BounceTypeSoft, "default"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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, ¬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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user