@@ 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
+}
@@ 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
+)
@@ 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=