~netlandish/gobwebs-ses-feedback

06517e1ca6abdd0914302511e8ec0252a8b31403 — Yader Velasquez 1 year, 11 months ago
Init
3 files changed, 247 insertions(+), 0 deletions(-)

A feedback.go
A go.mod
A go.sum
A  => feedback.go +200 -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
@@ 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
@@ 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=