mirror of
https://github.com/KevinMidboe/planetposen-mail.git
synced 2025-10-28 17:20:32 +00:00
Mail api for sending order confirmation to planetposen customers
This commit is contained in:
135
client/http.go
Normal file
135
client/http.go
Normal file
@@ -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
|
||||
}
|
||||
56
client/sendgrid/send_order_confirmation.go
Normal file
56
client/sendgrid/send_order_confirmation.go
Normal file
@@ -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
|
||||
}
|
||||
45
client/sendgrid/sendgrid.go
Normal file
45
client/sendgrid/sendgrid.go
Normal file
@@ -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"`
|
||||
}
|
||||
32
cmd/server/main.go
Normal file
32
cmd/server/main.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
28
config/config.go
Normal file
28
config/config.go
Normal file
@@ -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
|
||||
}
|
||||
208
mail/mail-template_order-confirmation.html
Normal file
208
mail/mail-template_order-confirmation.html
Normal file
@@ -0,0 +1,208 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" style="background: #ffffff;margin: 0 auto;padding: 0;width: 100%;height: 100%;background-color: #ffffff;">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
|
||||
<title>{{.PageTitle}}</title>
|
||||
<style type="text/css">@media only screen and (max-width:575px){.fs-sm-16px{font-size:16px !important}.fs-sm-20px{font-size:20px !important}.pb-sm-56px{padding-bottom:56px !important}.pt-sm-56px{padding-top:56px !important}}@media (max-width:575px){.table td,.table th{padding:12px 5px !important}.table *{font-size:14px !important}.container .container-content{border-radius:0 !important}.btn.btn-lg .btn-content{padding:15px 34px !important;font-size:18px !important}}</style>
|
||||
<meta name="x-apple-disable-message-reformatting">
|
||||
<meta name="format-detection" content="telephone=no,address=no,email=no,date=no,url=no">
|
||||
<style type="text/css">
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
|
||||
<style type="text/css">
|
||||
.col, div.col { width:100% !important; max-width:100% !important; }
|
||||
.hidden-outlook, .hidden-outlook table {
|
||||
display:none !important;
|
||||
mso-hide:all !important;
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: sans-serif !important;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
-ms-interpolation-mode:bicubic;
|
||||
}
|
||||
|
||||
td.body-content {
|
||||
width: 680px;
|
||||
}
|
||||
|
||||
td.row-content {
|
||||
font-size:0;
|
||||
}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<style type="text/css">
|
||||
|
||||
.visible-outlook {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
a[x-apple-data-detectors],
|
||||
.unstyle-auto-detected-links a {
|
||||
border-bottom: 0 !important;
|
||||
cursor: default !important;
|
||||
color: inherit !important;
|
||||
text-decoration: none !important;
|
||||
font-size: inherit !important;
|
||||
font-family: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
line-height: inherit !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #f1f1f2;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f1f1f2;
|
||||
}
|
||||
|
||||
body > table {
|
||||
background: #f1f1f2;
|
||||
border-collapse: collapse;
|
||||
color: #373737;
|
||||
background-color: #f1f1f2;
|
||||
}
|
||||
|
||||
.btn:hover tbody tr td {
|
||||
background-color: red !important;
|
||||
}
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans:400,700">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><style type="text/css">.ms-t1{line-height:1.45;font-family:Roboto,RobotoDraft,Helvetica,Arial,sans-serif;color:#373737;font-size:16px;text-align:left;font-weight:400}</style><![endif]-->
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<!--[if mso]><td></td><![endif]-->
|
||||
<td class="body-content" style="font-size: 16px;font-family: Roboto, RobotoDraft, Helvetica, Arial, sans-serif;font-weight: normal;line-height: 1.45;text-align: left;-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;color: #373737;">
|
||||
<h1 class="text-center fs-sm-20px" style="mso-line-height-rule: exactly;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;font-weight: 700;margin: 32px;color: black;font-size: 24px;text-align: center;">{{.PageTitle}}</h1>
|
||||
|
||||
<table class="ms-t1 container" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse: collapse;width: 100%;max-width: 680px;margin: 0 auto;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="container-content pt-sm-56px pb-sm-56px" style="background: #fff;padding: 0px;vertical-align: top;max-width: 680px;background-color: #fff;border-radius: 8px;padding-top: 56px;padding-bottom: 56px;">
|
||||
<center><svg class="icon bi bi-shop shop-icon fs-sm-70px" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16" preserveAspectRatio="none" style="font-size: 86px;color: var(--primary); width: 80px;">
|
||||
<path d="M2.97 1.35A1 1 0 0 1 3.73 1h8.54a1 1 0 0 1 .76.35l2.609 3.044A1.5 1.5 0 0 1 16 5.37v.255a2.375 2.375 0 0 1-4.25 1.458A2.371 2.371 0 0 1 9.875 8 2.37 2.37 0 0 1 8 7.083 2.37 2.37 0 0 1 6.125 8a2.37 2.37 0 0 1-1.875-.917A2.375 2.375 0 0 1 0 5.625V5.37a1.5 1.5 0 0 1 .361-.976l2.61-3.045zm1.78 4.275a1.375 1.375 0 0 0 2.75 0 .5.5 0 0 1 1 0 1.375 1.375 0 0 0 2.75 0 .5.5 0 0 1 1 0 1.375 1.375 0 1 0 2.75 0V5.37a.5.5 0 0 0-.12-.325L12.27 2H3.73L1.12 5.045A.5.5 0 0 0 1 5.37v.255a1.375 1.375 0 0 0 2.75 0 .5.5 0 0 1 1 0zM1.5 8.5A.5.5 0 0 1 2 9v6h1v-5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v5h6V9a.5.5 0 0 1 1 0v6h.5a.5.5 0 0 1 0 1H.5a.5.5 0 0 1 0-1H1V9a.5.5 0 0 1 .5-.5zM4 15h3v-5H4v5zm5-5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3zm3 0h-2v3h2v-3z"></path>
|
||||
</svg>
|
||||
<h2 style="margin: 0 0 10px 0;mso-line-height-rule: exactly;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;font-weight: 700;font-size: 24px;line-height: 1.8;margin-top: 18px;margin-bottom: 56px;margin-left: 18px;margin-right: 18px;" class="fs-sm-20px"><strong>Thank You For Your Order,</strong><br><strong>David Smith</strong></h2>
|
||||
<table class="ms-t1 layout-table" role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse: collapse;width: 90%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="color: #606060;font-size: 18px;padding-bottom: 25px;" class="fs-sm-16px">July 22, 2025</td>
|
||||
<td class="text-right fs-sm-16px" style="color: #606060;font-size: 18px;padding-bottom: 25px;text-align: right;">Order id: <strong>{{.OrderId}}</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="ms-t1 table" border="0" cellpadding="0" cellspacing="0" style="border-collapse: collapse;width: 100%;margin-bottom: 16px;">
|
||||
<thead>
|
||||
<tr style="background: ghostwhite;background-color: ghostwhite;">
|
||||
<th class="text-left" style="border-top: 1px solid #e9e9e9;padding: 20px 30px;vertical-align: bottom;border-bottom: 2px solid #e9e9e9;font-weight: 700;font-size: 14px;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;text-align: left;" colspan="4">ITEM</th>
|
||||
<th style="border-top: 1px solid #e9e9e9;padding: 20px 30px;vertical-align: bottom;border-bottom: 2px solid #e9e9e9;font-weight: 700;font-size: 14px;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;">QTY</th>
|
||||
<th style="border-top: 1px solid #e9e9e9;padding: 20px 30px;vertical-align: bottom;border-bottom: 2px solid #e9e9e9;font-weight: 700;font-size: 14px;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;">PRICE</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Products }}
|
||||
<tr>
|
||||
<td class="text-left" colspan="1" style="vertical-align: middle;border-top: 1px solid #e9e9e9;padding: 20px 30px;padding-right: 0px;text-align: left;"><img class="w-sm-50px" src={{.Image}} style="width: 120px;" alt="Item 1" /></td>
|
||||
<td class="text-left" colspan="3" style="vertical-align: middle;border-top: 1px solid #e9e9e9;padding: 20px 30px;padding-left: 0px;padding-right: 0px;text-align: left;">
|
||||
<h2 style="margin: 0 0 10px 0;mso-line-height-rule: exactly;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;font-weight: 700;font-size: 18px;margin-bottom: 5px;"><strong>{{.Name}}</strong></h2><span style="color: #9a9a9a;font-size: 16px;">{{.Description}}</span>
|
||||
</td>
|
||||
<td class="text-center" style="vertical-align: middle;border-top: 1px solid #e9e9e9;padding: 20px 30px;font-weight: 400;font-size: 18px;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;text-align: center;">{{.Quantity}}</td>
|
||||
<td class="text-center" style="vertical-align: middle;border-top: 1px solid #e9e9e9;padding: 20px 30px;font-weight: 400;font-size: 18px;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;text-align: center;">{{.Currency}} {{.Price}}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
|
||||
<!-- Tax & Total -->
|
||||
<tr>
|
||||
<td class="text-left" style="vertical-align: middle;border-top: 1px solid #e9e9e9;padding: 20px 30px;font-size: 18px;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;padding-right: 0px;text-align: left;" colspan="3">TAX</td>
|
||||
<td class="text-right" style="vertical-align: middle;border-top: 1px solid #e9e9e9;padding: 20px 30px;font-size: 18px;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;text-align: right;" colspan="3">$0.00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-left" style="vertical-align: middle;border-top: 1px solid #e9e9e9;padding: 20px 30px;font-size: 18px;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;padding-right: 0px;text-align: left;" colspan="3">Total</td>
|
||||
<td class="text-right" style="vertical-align: middle;border-top: 1px solid #e9e9e9;padding: 20px 30px;font-size: 18px;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;font-weight: 700;text-align: right;" colspan="3">$349.97</td>
|
||||
</tr>
|
||||
<!-- End tax & Total -->
|
||||
|
||||
<!-- Payment method & status -->
|
||||
<tr style="background: ghostwhite;background-color: ghostwhite;text-transform: uppercase;">
|
||||
<td class="text-left" style="vertical-align: middle;border-top: 1px solid #e9e9e9;padding: 20px 30px;font-weight: 700;font-size: 14px;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;width: 50%;text-align: left;" colspan="3">payment method</td>
|
||||
<td class="text-left" style="vertical-align: middle;border-top: 1px solid #e9e9e9;padding: 20px 30px;font-weight: 700;font-size: 14px;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;text-align: left;" colspan="3">status</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-left" style="border-top: 1px solid #e9e9e9;padding: 20px 30px;font-size: 16px;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;color: #5b5959;vertical-align: top;width: 50%;padding-bottom: 32px;text-align: left;" colspan="3">
|
||||
<h4 style="margin: 0 0 10px 0;mso-line-height-rule: exactly;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;font-weight: 700;font-size: 16px;line-height: 1.8;margin-bottom: 0px;">Credit/Debit Card<br></h4>
|
||||
<p style="margin: 0 0 10px 0;mso-line-height-rule: exactly;line-height: 1.8;">VISA (xxxxxxxxxxxx3422) <br>Valid Thru 11/2024</p>
|
||||
</td>
|
||||
<td class="text-left" style="border-top: 1px solid #e9e9e9;padding: 20px 30px;font-size: 16px;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;color: #5b5959;vertical-align: top;padding-bottom: 32px;text-align: left;" colspan="3">
|
||||
<h4 style="margin: 0 0 10px 0;mso-line-height-rule: exactly;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;font-weight: 700;font-size: 16px;line-height: 1.8;margin-bottom: 0px;"><strong>Paid on July 22, 2020</strong><br></h4>
|
||||
<p style="margin: 0 0 10px 0;mso-line-height-rule: exactly;line-height: 1.8;">Created, shipment pending (<a href="#" style="text-decoration: none;color: #3535e4;">track</a>)</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- End payment method & status -->
|
||||
|
||||
<!-- Shipping -->
|
||||
<tr style="background: ghostwhite;background-color: ghostwhite;text-transform: uppercase;">
|
||||
<td class="text-left" style="vertical-align: middle;border-top: 1px solid #e9e9e9;padding: 20px 30px;font-weight: 700;font-size: 14px;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;width: 50%;text-align: left;" colspan="3">shipping address</td>
|
||||
<td class="text-left" style="vertical-align: middle;border-top: 1px solid #e9e9e9;padding: 20px 30px;font-weight: 700;font-size: 14px;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;text-align: left;" colspan="3">shipping method</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-left" style="border-top: 1px solid #e9e9e9;padding: 20px 30px;font-size: 16px;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;color: #5b5959;vertical-align: top;width: 50%;padding-bottom: 32px;text-align: left;" colspan="3">
|
||||
<p style="margin: 0 0 10px 0;mso-line-height-rule: exactly;line-height: 1.6;"><strong>David Smith</strong><br>1234 Street Name<br>City, State 12345<br>(123) 555-32234</p>
|
||||
</td>
|
||||
<td class="text-left" style="border-top: 1px solid #e9e9e9;padding: 20px 30px;font-size: 16px;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;color: #5b5959;vertical-align: top;padding-bottom: 32px;text-align: left;" colspan="3">
|
||||
<h4 style="margin: 0 0 10px 0;mso-line-height-rule: exactly;font-family: "Open Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;font-weight: 700;font-size: 16px;line-height: 1.8;margin-bottom: 0px;"><strong>Express Shipping</strong><br></h4>
|
||||
<p style="margin: 0 0 10px 0;mso-line-height-rule: exactly;line-height: 1.8;">4-7 dager</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- End shipping -->
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- View order button -->
|
||||
<table class="ms-t1 btn btn-primary btn-lg btn-pill" border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse: separate;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="btn-content" style="background: #3535e4;border: none;cursor: pointer;text-align: center;vertical-align: middle;font-weight: 800;background-color: #3535e4;border-radius: 100px;padding: 18px 40px;"><a href="#" style="text-decoration: none;color: #fff;font-size: 125%;">View My Order</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- End view order button -->
|
||||
</center>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="text-center" style="color: #323233;text-align: center;">
|
||||
<p style="margin: 0 0 10px 0;mso-line-height-rule: exactly;line-height: 1.5;margin-top: 50px;margin-bottom: 25px;"><a href="https://planetposen.no">planetposen.no</a></p>
|
||||
</div>
|
||||
</td><!--[if mso]>
|
||||
<td></td> <![endif]--></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
63
mail/order_confirmation.go
Normal file
63
mail/order_confirmation.go
Normal file
@@ -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
|
||||
}
|
||||
26
mail/template.go
Normal file
26
mail/template.go
Normal file
@@ -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
|
||||
}
|
||||
55
server/handler/confirmation.go
Normal file
55
server/handler/confirmation.go
Normal file
@@ -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
|
||||
}
|
||||
24
server/handler/error.go
Normal file
24
server/handler/error.go
Normal file
@@ -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)
|
||||
}
|
||||
11
server/handler/healthz.go
Normal file
11
server/handler/healthz.go
Normal file
@@ -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)))
|
||||
}
|
||||
14
server/router.go
Normal file
14
server/router.go
Normal file
@@ -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")
|
||||
}
|
||||
85
server/server.go
Normal file
85
server/server.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user