From 2812f6bcbfeffd750b6c23dd7df2970c97a91285 Mon Sep 17 00:00:00 2001 From: Kevin Midboe Date: Fri, 2 Dec 2022 18:26:07 +0100 Subject: [PATCH] Mail api for sending order confirmation to planetposen customers --- client/http.go | 135 +++++++++++++ client/sendgrid/send_order_confirmation.go | 56 ++++++ client/sendgrid/sendgrid.go | 45 +++++ cmd/server/main.go | 32 ++++ config/config.go | 28 +++ mail/mail-template_order-confirmation.html | 208 +++++++++++++++++++++ mail/order_confirmation.go | 63 +++++++ mail/template.go | 26 +++ server/handler/confirmation.go | 55 ++++++ server/handler/error.go | 24 +++ server/handler/healthz.go | 11 ++ server/router.go | 14 ++ server/server.go | 85 +++++++++ 13 files changed, 782 insertions(+) create mode 100644 client/http.go create mode 100644 client/sendgrid/send_order_confirmation.go create mode 100644 client/sendgrid/sendgrid.go create mode 100644 cmd/server/main.go create mode 100644 config/config.go create mode 100644 mail/mail-template_order-confirmation.html create mode 100644 mail/order_confirmation.go create mode 100644 mail/template.go create mode 100644 server/handler/confirmation.go create mode 100644 server/handler/error.go create mode 100644 server/handler/healthz.go create mode 100644 server/router.go create mode 100644 server/server.go diff --git a/client/http.go b/client/http.go new file mode 100644 index 0000000..a050ee8 --- /dev/null +++ b/client/http.go @@ -0,0 +1,135 @@ +// Package client contains a HTTP client. +package client + +import ( + "bytes" + "context" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "time" + + opentracing "github.com/opentracing/opentracing-go" +) + +// Parameters provides the parameters used when creating a new HTTP client. +type Parameters struct { + Timeout *time.Duration +} + +// NewHTTPClient instantiates a new HTTPClient based on provided parameters. +func NewHTTPClient(parameters Parameters) HTTPClient { + if parameters.Timeout == nil { + timeout := 1 * time.Second + parameters.Timeout = &timeout + } + + client := &http.Client{ + Timeout: *parameters.Timeout, + } + + return HTTPClient{client} +} + +// HTTPRequestData contains the request data. +type HTTPRequestData struct { + Method string + URL string + Headers map[string]string + PostPayload []byte + GetPayload *url.Values +} + +// HTTPClient contains the HTTP client. +type HTTPClient struct { + *http.Client +} + +// HTTPStatusCodeError is an error that occurs when receiving an unexpected status code (>= 400). +type HTTPStatusCodeError struct { + URL string + StatusCode int + Message string +} + +// Error return an error string. +func (e HTTPStatusCodeError) Error() string { + return fmt.Sprintf("Error response from %s, got status: %d", e.URL, e.StatusCode) +} + +// RequestBytes does the actual HTTP request. +// Returns a slice of bytes or an error. +func (client *HTTPClient) RequestBytes(ctx context.Context, reqData HTTPRequestData) ([]byte, error) { + span, ctx := opentracing.StartSpanFromContext(ctx, "RequestBytes") + defer span.Finish() + + r, err := client.request(ctx, reqData) + + if err != nil { + return nil, err + } + + defer r.Body.Close() + + if r.StatusCode >= 400 { + resp, _ := ioutil.ReadAll(r.Body) + message := string(resp) + span.SetTag("error", true) + span.LogKV("message", fmt.Errorf("error making request to %s, got error: %s", reqData.URL, message)) + return nil, HTTPStatusCodeError{ + URL: reqData.URL, + StatusCode: r.StatusCode, + Message: message, + } + } + + return ioutil.ReadAll(r.Body) +} + +func (client *HTTPClient) request(ctx context.Context, reqData HTTPRequestData) (*http.Response, error) { + var req *http.Request + var err error + + if reqData.Method == http.MethodPost { + req, err = http.NewRequest(reqData.Method, reqData.URL, bytes.NewBuffer(reqData.PostPayload)) + } else { + req, err = http.NewRequest(reqData.Method, reqData.URL, nil) + } + + if err != nil { + return nil, err + } + + if reqData.GetPayload != nil { + req.URL.RawQuery = reqData.GetPayload.Encode() + } + + span := opentracing.SpanFromContext(ctx) + + if span != nil { + opentracing.GlobalTracer().Inject( + span.Context(), + opentracing.HTTPHeaders, + opentracing.HTTPHeadersCarrier(req.Header), + ) + } + + for k, v := range reqData.Headers { + req.Header.Set(k, v) + } + + req.Header.Set("User-Agent", "planetposen-mail") + + resp, err := client.Do(req) + + if err != nil { + if reqData.Method == http.MethodPost { + return resp, fmt.Errorf("Error making request: %v. Body: %s", err, reqData.PostPayload) + } + + return resp, fmt.Errorf("Error making request: %v. Query: %v", err, req.URL.RawQuery) + } + + return resp, nil +} \ No newline at end of file diff --git a/client/sendgrid/send_order_confirmation.go b/client/sendgrid/send_order_confirmation.go new file mode 100644 index 0000000..534e7c8 --- /dev/null +++ b/client/sendgrid/send_order_confirmation.go @@ -0,0 +1,56 @@ +package sendgrid + +import ( + "context" + "encoding/json" + "fmt" + "github.com/kevinmidboe/planetposen-mail/client" + "github.com/kevinmidboe/planetposen-mail/mail" + "net/http" +) + +// SendOrderConfirmation sends an order confirmation. +func (c *Client) SendOrderConfirmation(ctx context.Context, record mail.OrderConfirmationEmailData) error { + reqBody := sendEmailPayload{ + Personalizations: []personalization{ + { + To: []email{ + { + Email: record.ToEmail, + }, + }, + Subject: record.Subject, + }, + }, + From: email{ + Email: record.FromEmail, + Name: record.FromName, + }, + Content: []content{ + { + Type: "text/html", + Value: record.Markup, + }, + }, + } + jsonPayload, err := json.Marshal(reqBody) + + if err != nil { + return fmt.Errorf("error marshalling sendEmailPayload: %w", err) + } + reqData := client.HTTPRequestData{ + Method: http.MethodPost, + URL: fmt.Sprintf("%s/v3/mail/send", c.Endpoint), + Headers: map[string]string{ + "Content-Type": "application/json", + "Authorization": fmt.Sprintf("Bearer %s", c.APIKey), + }, + PostPayload: jsonPayload, + } + _, err = c.HTTPClient.RequestBytes(ctx, reqData) + if err != nil { + return fmt.Errorf("error making request to sendgrid to send email: %w", err) + } + + return nil +} diff --git a/client/sendgrid/sendgrid.go b/client/sendgrid/sendgrid.go new file mode 100644 index 0000000..c16ff4f --- /dev/null +++ b/client/sendgrid/sendgrid.go @@ -0,0 +1,45 @@ +package sendgrid + +import ( + "time" + + "github.com/kevinmidboe/planetposen-mail/client" + "github.com/kevinmidboe/planetposen-mail/config" +) + +// Client holds the HTTP client and endpoint information. +type Client struct { + Endpoint string + APIKey string + HTTPClient client.HTTPClient +} + +// Init sets up a new sendgrid client. +func (c *Client) Init(config *config.Config) error { + timeout := 5 * time.Second + c.Endpoint = config.SendGridAPIEndpoint + c.APIKey = config.SendGridAPIKey + c.HTTPClient = client.NewHTTPClient(client.Parameters{Timeout: &timeout}) + return nil +} + +type sendEmailPayload struct { + Personalizations []personalization `json:"personalizations"` + From email `json:"from"` + Content []content `json:"content"` +} + +type personalization struct { + To []email `json:"to"` + Subject string `json:"subject"` +} + +type email struct { + Email string `json:"email"` + Name string `json:"name"` +} + +type content struct { + Type string `json:"type"` + Value string `json:"value"` +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..3e33a0b --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "context" + + "github.com/kevinmidboe/planetposen-mail/config" + "github.com/kevinmidboe/planetposen-mail/server" + log "github.com/sirupsen/logrus" +) + +func main() { + // log.SetFormatter(logrustic.NewFormatter("planetposen-mail")) + + log.Info("Starting ...") + + ctx := context.Background() + config, err := config.LoadConfig() + + if err != nil { + log.Fatal(err.Error()) + } + + var s server.Server + + if err := s.Create(ctx, config); err != nil { + log.Fatal(err.Error()) + } + + if err := s.Serve(ctx); err != nil { + log.Fatal(err.Error()) + } +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..60b7c59 --- /dev/null +++ b/config/config.go @@ -0,0 +1,28 @@ +// Package config handles environment variables. +package config + +import ( + "github.com/joho/godotenv" + "github.com/kelseyhightower/envconfig" + log "github.com/sirupsen/logrus" +) + +// Config contains environment variables. +type Config struct { + Port string `envconfig:"PORT" default:"8000"` + SendGridAPIEndpoint string `envconfig:"SEND_GRID_API_ENDPOINT" required:"true"` + SendGridAPIKey string `envconfig:"SEND_GRID_API_KEY" required:"true"` +} + +// LoadConfig reads environment variables, populates and returns Config. +func LoadConfig() (*Config, error) { + if err := godotenv.Load(); err != nil { + log.Info("No .env file found") + } + + var c Config + + err := envconfig.Process("", &c) + + return &c, err +} \ No newline at end of file diff --git a/mail/mail-template_order-confirmation.html b/mail/mail-template_order-confirmation.html new file mode 100644 index 0000000..1a9ba5f --- /dev/null +++ b/mail/mail-template_order-confirmation.html @@ -0,0 +1,208 @@ + + + + + + + {{.PageTitle}} + + + + + + + + + + + + + + + + + + + + + +
+

{{.PageTitle}}

+ + + + + + + + + +
+ + + diff --git a/mail/order_confirmation.go b/mail/order_confirmation.go new file mode 100644 index 0000000..7886ffd --- /dev/null +++ b/mail/order_confirmation.go @@ -0,0 +1,63 @@ +package mail + +import ( + "context" + "fmt" +) + +type OrderMailSender interface { + SendOrderConfirmation(ctx context.Context, record Record) error +} + +type Product struct { + Name string + Image string + Description string + Quantity int + Price float32 + Currency string +} + +type OrderConfirmationData struct { + // PageTitle string + Email string + OrderId string + Products []Product +} + +type EmailTemplateData struct { + PageTitle string + OrderId string + Products []Product +} + +type OrderConfirmationEmailData struct { + Subject string + FromName string + FromEmail string + ToEmail string + Markup string +} + +type Record struct { + Email string + // FullName string + Status string + OrderConfirmationEmailData OrderConfirmationEmailData +} + +func OrderConfirmation(payload OrderConfirmationData) (*OrderConfirmationEmailData, error) { + var emailTemplate EmailTemplateData + emailTemplate.PageTitle = "Planetposen purchase" + emailTemplate.OrderId = payload.OrderId + emailTemplate.Products = payload.Products + + orderConfirmationEmailData := buildOrderConfirmation(emailTemplate) + if orderConfirmationEmailData == nil { + return nil, fmt.Errorf("couldn't build order confirmation template for orderId %s", payload.OrderId) + } + + orderConfirmationEmailData.ToEmail = payload.Email + + return orderConfirmationEmailData, nil +} diff --git a/mail/template.go b/mail/template.go new file mode 100644 index 0000000..2e83c8a --- /dev/null +++ b/mail/template.go @@ -0,0 +1,26 @@ +package mail + +import ( + "html/template" + "strings" +) + +func buildOrderConfirmation(templateData EmailTemplateData) *OrderConfirmationEmailData { + subject := "Orderbekreftelse fra planetposen.no" + + data := &OrderConfirmationEmailData{ + Subject: subject, + FromName: "noreply@kevm.dev", + FromEmail: "noreply@kevm.dev", + } + + tmpl := template.Must(template.ParseFiles("mail/mail-template_order-confirmation.html")) + b := new(strings.Builder) + err := tmpl.Execute(b, templateData) + if err != nil { + return nil + } + + data.Markup = b.String() + return data +} diff --git a/server/handler/confirmation.go b/server/handler/confirmation.go new file mode 100644 index 0000000..7a3f0f2 --- /dev/null +++ b/server/handler/confirmation.go @@ -0,0 +1,55 @@ +// Package handler contains handlers for events. +package handler + +import ( + "encoding/json" + "fmt" + "github.com/kevinmidboe/planetposen-mail/client/sendgrid" + "github.com/kevinmidboe/planetposen-mail/mail" + "net/http" +) + +func SendOrderConfirmation(s *sendgrid.Client) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + payload, err := getOrderConfirmationPayload(r) + + if err != nil { + handleError(w, err, "unable to parse order payload", http.StatusBadRequest, true) + return + } + mailData, err := mail.OrderConfirmation(*payload) + + err = s.SendOrderConfirmation(ctx, *mailData) + if err != nil { + fmt.Println(err) + handleError(w, err, "error from sendgrid ", http.StatusInternalServerError, true) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + responseJSON, _ := json.Marshal(struct { + Message string `json:"message"` + OrderId string `json:"orderId"` + Recipient string `json:"recipient"` + }{ + Message: "Successfully sent email", + OrderId: payload.OrderId, + Recipient: payload.Email, + }) + w.Write(responseJSON) + } +} + +func getOrderConfirmationPayload(r *http.Request) (*mail.OrderConfirmationData, error) { + decoder := json.NewDecoder(r.Body) + + var payload mail.OrderConfirmationData + err := decoder.Decode(&payload) + if err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + + return &payload, nil +} diff --git a/server/handler/error.go b/server/handler/error.go new file mode 100644 index 0000000..0dd9371 --- /dev/null +++ b/server/handler/error.go @@ -0,0 +1,24 @@ +package handler + +import ( + "encoding/json" + "net/http" + + log "github.com/sirupsen/logrus" +) + +// handleError - Logs the error (if shouldLog is true), and outputs the error message (msg) +func handleError(w http.ResponseWriter, err error, msg string, statusCode int, shouldLog bool) { + if shouldLog { + log.WithField("err", err).Error(msg) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + errorJSON, _ := json.Marshal(struct { + Error string `json:"error"` + }{ + Error: msg, + }) + w.Write(errorJSON) +} \ No newline at end of file diff --git a/server/handler/healthz.go b/server/handler/healthz.go new file mode 100644 index 0000000..3ff0a91 --- /dev/null +++ b/server/handler/healthz.go @@ -0,0 +1,11 @@ +package handler + +import "net/http" + +// Healthz is used for our readiness and liveness probes. +// GET /_healthz +// Responds: 200 +func Healthz(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(http.StatusText(http.StatusOK))) +} diff --git a/server/router.go b/server/router.go new file mode 100644 index 0000000..4855b1d --- /dev/null +++ b/server/router.go @@ -0,0 +1,14 @@ +package server + +import ( + "github.com/kevinmidboe/planetposen-mail/server/handler" +) + +const v1API string = "/api/v1/" + +func (s *Server) setupRoutes() { + s.Router.HandleFunc("/_healthz", handler.Healthz).Methods("GET").Name("Health") + + api := s.Router.PathPrefix(v1API).Subrouter() + api.HandleFunc("/send-confirmation", handler.SendOrderConfirmation(s.SendGrid)).Methods("POST").Name("SendOrderConfirmation") +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..c83f3c6 --- /dev/null +++ b/server/server.go @@ -0,0 +1,85 @@ +// Package server provides functionality to easily set up an HTTTP server. +// +// Clients: +// Database +package server + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/gorilla/mux" + "github.com/kevinmidboe/planetposen-mail/client/sendgrid" + "github.com/kevinmidboe/planetposen-mail/config" + log "github.com/sirupsen/logrus" +) + +// Server holds the HTTP server, router, config and all clients. +type Server struct { + Config *config.Config + HTTP *http.Server + Router *mux.Router + SendGrid *sendgrid.Client +} + +// Create sets up the HTTP server, router and all clients. +// Returns an error if an error occurs. +func (s *Server) Create(ctx context.Context, config *config.Config) error { + // metrics.RegisterPrometheusCollectors() + + s.Config = config + s.Router = mux.NewRouter() + s.HTTP = &http.Server{ + Addr: fmt.Sprintf(":%s", s.Config.Port), + Handler: s.Router, + } + + var sendGridClient sendgrid.Client + if err := sendGridClient.Init(config); err != nil { + return fmt.Errorf("error initializing sendgrid client: %w", err) + } + + s.SendGrid = &sendGridClient + + s.setupRoutes() + + return nil +} + +// Serve tells the server to start listening and serve HTTP requests. +// It also makes sure that the server gracefully shuts down on exit. +// Returns an error if an error occurs. +func (s *Server) Serve(ctx context.Context) error { + // closer, err := trace.InitGlobalTracer(s.Config) + + // if err != nil { + // return err + // } + + // defer closer.Close() + + go func(ctx context.Context, s *Server) { + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + + <-stop + + log.Info("Shutdown signal received") + + if err := s.HTTP.Shutdown(ctx); err != nil { + log.Error(err.Error()) + } + }(ctx, s) + + log.Infof("Ready at: %s", s.Config.Port) + + if err := s.HTTP.ListenAndServe(); err != http.ErrServerClosed { + log.Fatalf(err.Error()) + } + + return nil +}