feat: mailer internal, templates, bruno cleanup, create user, send welcome mail, mailtrap

This commit is contained in:
2026-03-26 09:46:32 +01:00
parent 095f7aabeb
commit bedc010584
16 changed files with 311 additions and 4 deletions
+16
View File
@@ -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()
}()
}
+20
View File
@@ -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()
+1
View File
@@ -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))
+85
View File
@@ -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,7 +1,7 @@
info:
name: Delete Movie
type: http
seq: 6
seq: 3
http:
method: DELETE
@@ -1,7 +1,7 @@
info:
name: Get Movies
type: http
seq: 3
seq: 2
http:
method: GET
@@ -1,7 +1,7 @@
info:
name: Get Single Movie
type: http
seq: 4
seq: 2
http:
method: GET
@@ -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
+5
View File
@@ -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
)
+4
View File
@@ -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}}