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.
|
||||
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"
|
||||
"greenlight.debuggingjon.dev/internal/data"
|
||||
"greenlight.debuggingjon.dev/internal/jsonlog"
|
||||
"greenlight.debuggingjon.dev/internal/mailer"
|
||||
)
|
||||
|
||||
// Declare a string containing the application version number. Later in the book we'll
|
||||
@@ -39,6 +40,13 @@ type config struct {
|
||||
burst int
|
||||
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,
|
||||
@@ -48,6 +56,7 @@ type application struct {
|
||||
config config
|
||||
logger *jsonlog.Logger
|
||||
models data.Models
|
||||
mailer mailer.Mailer
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -80,6 +89,16 @@ func main() {
|
||||
flag.IntVar(&cfg.limiter.burst, "limiter-burst", 4, "Rate limiter maximum burst")
|
||||
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()
|
||||
|
||||
// Call the openDB() helper function (see below) to create the connection pool,
|
||||
@@ -105,6 +124,7 @@ func main() {
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
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()
|
||||
|
||||
@@ -23,6 +23,7 @@ func (app *application) routes() http.Handler {
|
||||
router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.showMovieHandler)
|
||||
router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.updateMovieHandler)
|
||||
router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.deleteMovieHandler)
|
||||
router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler)
|
||||
|
||||
// Wrap the router with the rateLimit() middleware.
|
||||
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:
|
||||
name: Delete Movie
|
||||
type: http
|
||||
seq: 6
|
||||
seq: 3
|
||||
|
||||
http:
|
||||
method: DELETE
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Get Movies
|
||||
type: http
|
||||
seq: 3
|
||||
seq: 2
|
||||
|
||||
http:
|
||||
method: GET
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Get Single Movie
|
||||
type: http
|
||||
seq: 4
|
||||
seq: 2
|
||||
|
||||
http:
|
||||
method: GET
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Update Movie
|
||||
type: http
|
||||
seq: 5
|
||||
seq: 4
|
||||
|
||||
http:
|
||||
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/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/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
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/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
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