feat: mailer internal, templates, bruno cleanup, create user, send welcome mail, mailtrap
This commit is contained in:
@@ -175,3 +175,19 @@ func (app *application) readInt(qs url.Values, key string, defaultValue int, v *
|
|||||||
// Otherwise, return the converted integer value.
|
// Otherwise, return the converted integer value.
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The background() helper accepts an arbitrary function as a parameter.
|
||||||
|
func (app *application) background(fn func()) {
|
||||||
|
// Launch a background goroutine.
|
||||||
|
go func() {
|
||||||
|
// Recover any panic.
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
app.logger.PrintError(fmt.Errorf("%s", err), nil)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Execute the arbitrary function that we passed as the parameter.
|
||||||
|
fn()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
_ "github.com/lib/pq"
|
_ "github.com/lib/pq"
|
||||||
"greenlight.debuggingjon.dev/internal/data"
|
"greenlight.debuggingjon.dev/internal/data"
|
||||||
"greenlight.debuggingjon.dev/internal/jsonlog"
|
"greenlight.debuggingjon.dev/internal/jsonlog"
|
||||||
|
"greenlight.debuggingjon.dev/internal/mailer"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Declare a string containing the application version number. Later in the book we'll
|
// Declare a string containing the application version number. Later in the book we'll
|
||||||
@@ -39,6 +40,13 @@ type config struct {
|
|||||||
burst int
|
burst int
|
||||||
enabled bool
|
enabled bool
|
||||||
}
|
}
|
||||||
|
smtp struct {
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
sender string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define an application struct to hold the dependencies for our HTTP handlers, helpers,
|
// Define an application struct to hold the dependencies for our HTTP handlers, helpers,
|
||||||
@@ -48,6 +56,7 @@ type application struct {
|
|||||||
config config
|
config config
|
||||||
logger *jsonlog.Logger
|
logger *jsonlog.Logger
|
||||||
models data.Models
|
models data.Models
|
||||||
|
mailer mailer.Mailer
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -80,6 +89,16 @@ func main() {
|
|||||||
flag.IntVar(&cfg.limiter.burst, "limiter-burst", 4, "Rate limiter maximum burst")
|
flag.IntVar(&cfg.limiter.burst, "limiter-burst", 4, "Rate limiter maximum burst")
|
||||||
flag.BoolVar(&cfg.limiter.enabled, "limiter-enabled", true, "Enable rate limiter")
|
flag.BoolVar(&cfg.limiter.enabled, "limiter-enabled", true, "Enable rate limiter")
|
||||||
|
|
||||||
|
// Read the SMTP server configuration settings into the config struct, using the
|
||||||
|
// Mailtrap settings as the default values. IMPORTANT: If you're following along,
|
||||||
|
// make sure to replace the default values for smtp-username and smtp-password
|
||||||
|
// with your own Mailtrap credentials.
|
||||||
|
flag.StringVar(&cfg.smtp.host, "smtp-host", "sandbox.smtp.mailtrap.io", "SMTP host")
|
||||||
|
flag.IntVar(&cfg.smtp.port, "smtp-port", 25, "SMTP port")
|
||||||
|
flag.StringVar(&cfg.smtp.username, "smtp-username", "3d946287da35ea", "SMTP username")
|
||||||
|
flag.StringVar(&cfg.smtp.password, "smtp-password", "d06a774b484ca3", "SMTP password")
|
||||||
|
flag.StringVar(&cfg.smtp.sender, "smtp-sender", "Greenlight <no-reply@greenlight.debuggingjon.dev>", "SMTP sender")
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// Call the openDB() helper function (see below) to create the connection pool,
|
// Call the openDB() helper function (see below) to create the connection pool,
|
||||||
@@ -105,6 +124,7 @@ func main() {
|
|||||||
config: cfg,
|
config: cfg,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
models: data.NewModels(db),
|
models: data.NewModels(db),
|
||||||
|
mailer: mailer.New(cfg.smtp.host, cfg.smtp.port, cfg.smtp.username, cfg.smtp.password, cfg.smtp.sender),
|
||||||
}
|
}
|
||||||
|
|
||||||
err = app.serve()
|
err = app.serve()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ func (app *application) routes() http.Handler {
|
|||||||
router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.showMovieHandler)
|
router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.showMovieHandler)
|
||||||
router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.updateMovieHandler)
|
router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.updateMovieHandler)
|
||||||
router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.deleteMovieHandler)
|
router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.deleteMovieHandler)
|
||||||
|
router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler)
|
||||||
|
|
||||||
// Wrap the router with the rateLimit() middleware.
|
// Wrap the router with the rateLimit() middleware.
|
||||||
return app.recoverPanic(app.rateLimit(router))
|
return app.recoverPanic(app.rateLimit(router))
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"greenlight.debuggingjon.dev/internal/data"
|
||||||
|
"greenlight.debuggingjon.dev/internal/validator"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Create an anonymous struct to hold the expected data from the request body.
|
||||||
|
var input struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the request body into the anonymous struct.
|
||||||
|
err := app.readJSON(w, r, &input)
|
||||||
|
if err != nil {
|
||||||
|
app.badRequestResponse(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the data from the request body into a new User struct. Notice also that we
|
||||||
|
// set the Activated field to false, which isn't strictly necessary because the
|
||||||
|
// Activated field will have the zero-value of false by default. But setting this
|
||||||
|
// explicitly helps to make our intentions clear to anyone reading the code.
|
||||||
|
user := &data.User{
|
||||||
|
Name: input.Name,
|
||||||
|
Email: input.Email,
|
||||||
|
Activated: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the Password.Set() method to generate and store the hashed and plaintext
|
||||||
|
// passwords.
|
||||||
|
err = user.Password.Set(input.Password)
|
||||||
|
if err != nil {
|
||||||
|
app.serverErrorResponse(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v := validator.New()
|
||||||
|
|
||||||
|
// Validate the user struct and return the error messages to the client if any of
|
||||||
|
// the checks fail.
|
||||||
|
if data.ValidateUser(v, user); !v.Valid() {
|
||||||
|
app.failedValidationResponse(w, r, v.Errors)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the user data into the database.
|
||||||
|
err = app.models.Users.Insert(user)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
// If we get a ErrDuplicateEmail error, use the v.AddError() method to manually
|
||||||
|
// add a message to the validator instance, and then call our
|
||||||
|
// failedValidationResponse() helper.
|
||||||
|
case errors.Is(err, data.ErrDuplicateEmail):
|
||||||
|
v.AddError("email", "a user with this email address already exists")
|
||||||
|
app.failedValidationResponse(w, r, v.Errors)
|
||||||
|
default:
|
||||||
|
app.serverErrorResponse(w, r, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the background helper to execute an anonymous function that sends the welcome
|
||||||
|
// email.
|
||||||
|
app.background(func() {
|
||||||
|
err = app.mailer.Send(user.Email, "user_welcome.tmpl", user)
|
||||||
|
if err != nil {
|
||||||
|
app.logger.PrintError(err, nil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Note that we also change this to send the client a 202 Accepted status code.
|
||||||
|
// This status code indicates that the request has been accepted for processing, but
|
||||||
|
// the processing has not been completed.
|
||||||
|
err = app.writeJSON(w, http.StatusAccepted, envelope{"user": user}, nil)
|
||||||
|
if err != nil {
|
||||||
|
app.serverErrorResponse(w, r, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Delete Movie
|
name: Delete Movie
|
||||||
type: http
|
type: http
|
||||||
seq: 6
|
seq: 3
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: DELETE
|
method: DELETE
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Get Movies
|
name: Get Movies
|
||||||
type: http
|
type: http
|
||||||
seq: 3
|
seq: 2
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Get Single Movie
|
name: Get Single Movie
|
||||||
type: http
|
type: http
|
||||||
seq: 4
|
seq: 2
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Update Movie
|
name: Update Movie
|
||||||
type: http
|
type: http
|
||||||
seq: 5
|
seq: 4
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: PATCH
|
method: PATCH
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
info:
|
||||||
|
name: Movies
|
||||||
|
type: folder
|
||||||
|
seq: 3
|
||||||
|
|
||||||
|
request:
|
||||||
|
auth: inherit
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
info:
|
||||||
|
name: Create User
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: "{{URL}}/v1/users"
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"name": "John Mike",
|
||||||
|
"email": "johnsmike@example.com",
|
||||||
|
"password": "pa55word"
|
||||||
|
}
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
info:
|
||||||
|
name: Users
|
||||||
|
type: folder
|
||||||
|
seq: 2
|
||||||
|
|
||||||
|
request:
|
||||||
|
auth: inherit
|
||||||
@@ -9,3 +9,8 @@ require (
|
|||||||
golang.org/x/crypto v0.49.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/time v0.15.0
|
golang.org/x/time v0.15.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-mail/mail/v2 v2.3.0 // indirect
|
||||||
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/go-mail/mail/v2 v2.3.0 h1:wha99yf2v3cpUzD1V9ujP404Jbw2uEvs+rBJybkdYcw=
|
||||||
|
github.com/go-mail/mail/v2 v2.3.0/go.mod h1:oE2UK8qebZAjjV1ZYUpY7FPnbi/kIU53l1dmqPRb4go=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
|
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
|
||||||
@@ -8,3 +10,5 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
|||||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||||
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package mailer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"embed"
|
||||||
|
"html/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-mail/mail/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Below we declare a new variable with the type embed.FS (embedded file system) to hold
|
||||||
|
// our email templates. This has a comment directive in the format `//go:embed <path>`
|
||||||
|
// IMMEDIATELY ABOVE it, which indicates to Go that we want to store the contents of the
|
||||||
|
// ./templates directory in the templateFS embedded file system variable.
|
||||||
|
// ↓↓↓
|
||||||
|
|
||||||
|
//go:embed "templates"
|
||||||
|
var templateFS embed.FS
|
||||||
|
|
||||||
|
// Define a Mailer struct which contains a mail.Dialer instance (used to connect to a
|
||||||
|
// SMTP server) and the sender information for your emails (the name and address you
|
||||||
|
// want the email to be from, such as "Alice Smith <alice@example.com>").
|
||||||
|
type Mailer struct {
|
||||||
|
dialer *mail.Dialer
|
||||||
|
sender string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(host string, port int, username, password, sender string) Mailer {
|
||||||
|
// Initialize a new mail.Dialer instance with the given SMTP server settings. We
|
||||||
|
// also configure this to use a 5-second timeout whenever we send an email.
|
||||||
|
dialer := mail.NewDialer(host, port, username, password)
|
||||||
|
dialer.Timeout = 5 * time.Second
|
||||||
|
|
||||||
|
// Return a Mailer instance containing the dialer and sender information.
|
||||||
|
return Mailer{
|
||||||
|
dialer: dialer,
|
||||||
|
sender: sender,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define a Send() method on the Mailer type. This takes the recipient email address
|
||||||
|
// as the first parameter, the name of the file containing the templates, and any
|
||||||
|
// dynamic data for the templates as an interface{} parameter.
|
||||||
|
func (m Mailer) Send(recipient, templateFile string, data any) error {
|
||||||
|
// Use the ParseFS() method to parse the required template file from the embedded
|
||||||
|
// file system.
|
||||||
|
tmpl, err := template.New("email").ParseFS(templateFS, "templates/"+templateFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the named template "subject", passing in the dynamic data and storing the
|
||||||
|
// result in a bytes.Buffer variable.
|
||||||
|
subject := new(bytes.Buffer)
|
||||||
|
|
||||||
|
err = tmpl.ExecuteTemplate(subject, "subject", data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow the same pattern to execute the "plainBody" template and store the result
|
||||||
|
// in the plainBody variable.
|
||||||
|
plainBody := new(bytes.Buffer)
|
||||||
|
err = tmpl.ExecuteTemplate(plainBody, "plainBody", data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// And likewise with the "htmlBody" template.
|
||||||
|
htmlBody := new(bytes.Buffer)
|
||||||
|
err = tmpl.ExecuteTemplate(htmlBody, "htmlBody", data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the mail.NewMessage() function to initialize a new mail.Message instance.
|
||||||
|
// Then we use the SetHeader() method to set the email recipient, sender and subject
|
||||||
|
// headers, the SetBody() method to set the plain-text body, and the AddAlternative()
|
||||||
|
// method to set the HTML body. It's important to note that AddAlternative() should
|
||||||
|
// always be called *after* SetBody().
|
||||||
|
msg := mail.NewMessage()
|
||||||
|
msg.SetHeader("To", recipient)
|
||||||
|
msg.SetHeader("From", m.sender)
|
||||||
|
msg.SetHeader("Subject", subject.String())
|
||||||
|
msg.SetBody("text/plain", plainBody.String())
|
||||||
|
msg.AddAlternative("text/html", htmlBody.String())
|
||||||
|
|
||||||
|
// Call the DialAndSend() method on the dialer, passing in the message to send. This
|
||||||
|
// opens a connection to the SMTP server, sends the message, then closes the
|
||||||
|
// connection. If there is a timeout, it will return a "dial tcp: i/o timeout"
|
||||||
|
// error.
|
||||||
|
// Try sending the email up to three times before aborting and returning the final
|
||||||
|
// error. We sleep for 500 milliseconds between each attempt.
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
err = m.dialer.DialAndSend(msg)
|
||||||
|
// If everything worked, return nil.
|
||||||
|
if nil == err {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// If it didn't work, sleep for a short time and retry.
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{{define "subject"}}Welcome to Greenlight!{{end}}
|
||||||
|
|
||||||
|
{{define "plainBody"}}
|
||||||
|
Hi,
|
||||||
|
|
||||||
|
Thanks for signing up for a Greenlight account. We're excited to have you on board!
|
||||||
|
|
||||||
|
For future reference, your user ID number is {{.ID}}.
|
||||||
|
|
||||||
|
Thanks,
|
||||||
|
|
||||||
|
The Greenlight Team
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "htmlBody"}}
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<p>Hi,</p>
|
||||||
|
<p>Thanks for signing up for a Greenlight account. We're excited to have you on board!</p>
|
||||||
|
<p>For future reference, your user ID number is {{.ID}}.</p>
|
||||||
|
<p>Thanks,</p>
|
||||||
|
<p>The Greenlight Team</p>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user