3 files changed, 247 insertions(+), 0 deletions(-)

A => feedback.go
A => go.mod
A => go.sum
A => feedback.go +200 -0
@@ 0,0 1,200 @@ 
+package sesfeedback
+
+import (
+	"bytes"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/json"
+	"encoding/pem"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"reflect"
+
+	"github.com/labstack/echo/v4"
+	"hg.code.netlandish.com/~netlandish/gobwebs/server"
+)
+
+// Actions ...
+type Actions interface {
+	Bounce(data map[string]any) error
+	Complaint(data map[string]any) error
+	Delivery(data map[string]any) error
+	Send(data map[string]any) error
+	Reject(data map[string]any) error
+	Open(data map[string]any) error
+	Click(data map[string]any) error
+	Subscribe(data string) error
+	Unsubscribe(data string) error
+}
+
+// Service ...
+type Service struct {
+	name    string
+	eg      *echo.Group
+	actions Actions
+	verify  bool
+}
+
+// RegisterRoutes ...
+func (s *Service) RegisterRoutes() {
+	s.eg.POST("/ses-feedback", s.Feedback).Name = s.RouteName("feedback")
+}
+
+// RouteName ...
+func (s *Service) RouteName(value string) string {
+	return fmt.Sprintf("%s:%s", s.name, value)
+}
+
+// Record ...
+type Record struct {
+	Message          string `json:"Message"`
+	MessageID        string `json:"MessageId"`
+	Signature        string `json:"Signature"`
+	SignatureVersion string `json:"SignatureVersion"`
+	SigningCertURL   string `json:"SigningCertURL"`
+	SubscribeURL     string `json:"SubscribeURL"`
+	Subject          string `json:"Subject"`
+	Timestamp        string `json:"Timestamp"`
+	Token            string `json:"Token"`
+	TopicArn         string `json:"TopicArn"`
+	Type             string `json:"Type"`
+	UnsubscribeURL   string `json:"UnsubscribeURL"`
+}
+
+func (r Record) getBytesToSign() []byte {
+	var fields []string
+	var lines bytes.Buffer
+	if r.Type == "Notification" {
+		fields = []string{"Message", "MessageID", "Subject", "Timestamp", "TopicArn", "Type"}
+	} else if r.Type == "SubscriptionConfirmation" || r.Type == "UnsubscribeConfirmation" {
+		fields = []string{"Message", "MessageID", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type"}
+	}
+	structValues := reflect.ValueOf(r)
+	// We want to use the tag json to get the right field name
+	structMeta := reflect.TypeOf(r)
+	for _, key := range fields {
+		field := reflect.Indirect(structValues).FieldByName(key)
+		value := field.String()
+		meta, _ := structMeta.FieldByName(key)
+		if field.IsValid() && value != "" {
+			lines.WriteString(meta.Tag.Get("json") + "\n")
+			lines.WriteString(value + "\n")
+		}
+	}
+	return lines.Bytes()
+}
+
+func (r Record) signatureAlgorithm() x509.SignatureAlgorithm {
+	if r.SignatureVersion == "2" {
+		return x509.SHA256WithRSA
+	}
+	return x509.SHA1WithRSA
+}
+
+func (r Record) verify() error {
+	// Get signature
+	signature, err := base64.StdEncoding.DecodeString(r.Signature)
+	if err != nil {
+		return err
+	}
+	// We Get the certificate from AWS
+	resp, err := http.Get(r.SigningCertURL)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	// We read the certificate
+	certBytes, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return err
+	}
+	// We decode the cert and parse it in order to get the RSA Public Key
+	pem, _ := pem.Decode(certBytes)
+	if pem == nil {
+		return errors.New("The decoded PEM file was empty")
+	}
+	cert, err := x509.ParseCertificate(pem.Bytes)
+	if err != nil {
+		return err
+	}
+	return cert.CheckSignature(r.signatureAlgorithm(), r.getBytesToSign(), signature)
+}
+
+// Feedback is the weebhook handler that listen to the SES requests
+func (s *Service) Feedback(c echo.Context) error {
+	gctx := c.(*server.Context)
+	req := c.Request()
+	var data Record
+	err := json.NewDecoder(req.Body).Decode(&data)
+	if err != nil {
+		return c.JSON(http.StatusOK, err)
+	}
+
+	if s.verify {
+		err := data.verify()
+		if err != nil {
+			gctx.Server.Logger().Printf("Error: %s", err)
+			return c.JSON(http.StatusBadRequest, "Unverified Request")
+		}
+	}
+
+	switch data.Type {
+	case "Notification":
+		// For Notification, the Message field is a json-like string
+		var message map[string]any
+		json.Unmarshal([]byte(data.Message), &message)
+
+		// According to some settings `eventType` might be called `notificationType`
+		// https://docs.aws.amazon.com/ses/latest/dg/event-publishing-retrieving-sns-contents.html#event-publishing-retrieving-sns-contents-subscription-object
+		var eventType string
+		var ok bool
+		if eventType, ok = message["eventType"].(string); !ok {
+			eventType = message["notificationType"].(string)
+		}
+
+		switch eventType {
+		case "Bounce":
+			err = s.actions.Bounce(message)
+		case "Complaint":
+			err = s.actions.Complaint(message)
+		case "Delivery":
+			err = s.actions.Delivery(message)
+		case "Send":
+			err = s.actions.Send(message)
+		case "Reject":
+			err = s.actions.Reject(message)
+		case "Open":
+			err = s.actions.Open(message)
+		case "Click":
+			err = s.actions.Click(message)
+		}
+		gctx.Server.Logger().Printf("Received %s notification", eventType)
+
+	case "SubscriptionConfirmation":
+		err = s.actions.Subscribe(data.Message)
+		gctx.Server.Logger().Printf("Received %s notification", data.Type)
+	case "UnsubscribeConfirmation":
+		err = s.actions.Unsubscribe(data.Message)
+		gctx.Server.Logger().Printf("Received %s notification", data.Type)
+	default:
+		gctx.Server.Logger().Printf("Error: Received unknown notification type: %s.", data.Type)
+	}
+
+	if err != nil {
+		gctx.Server.Logger().Printf("Error: %s.", err)
+	}
+
+	// AWS will consider anything other than 200 to be an error response and
+	// resend the SNS request. We don't need that so we return 200 here.
+	return c.JSON(http.StatusOK, "200 OK.")
+}
+
+// NewService ...
+func NewService(eg *echo.Group, ac Actions, verify bool) *Service {
+	service := &Service{name: "ses-feedback", eg: eg, actions: ac, verify: verify}
+	service.RegisterRoutes()
+	return service
+}

          
A => go.mod +17 -0
@@ 0,0 1,17 @@ 
+module hg.code.netlandish.com/~netlandish/gobwebs-ses-feedback
+
+go 1.19
+
+require github.com/labstack/echo/v4 v4.9.1
+
+require (
+	github.com/labstack/gommon v0.4.0 // indirect
+	github.com/mattn/go-colorable v0.1.11 // indirect
+	github.com/mattn/go-isatty v0.0.14 // indirect
+	github.com/valyala/bytebufferpool v1.0.0 // indirect
+	github.com/valyala/fasttemplate v1.2.1 // indirect
+	golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect
+	golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f // indirect
+	golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect
+	golang.org/x/text v0.3.7 // indirect
+)

          
A => go.sum +30 -0
@@ 0,0 1,30 @@ 
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/labstack/echo/v4 v4.9.1 h1:GliPYSpzGKlyOhqIbG8nmHBo3i1saKWFOgh41AN3b+Y=
+github.com/labstack/echo/v4 v4.9.1/go.mod h1:Pop5HLc+xoc4qhTZ1ip6C0RtP7Z+4VzRLWZZFKqbbjo=
+github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
+github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
+github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs=
+github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
+github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
+github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
+golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ=
+golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f h1:OfiFi4JbukWwe3lzw+xunroH1mnC1e2Gy5cxNJApiSY=
+golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4=
+golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=