From bedc0105841d865b481fe6ebb43ca03356c21549 Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 26 Mar 2026 09:46:32 +0100 Subject: [PATCH] feat: mailer internal, templates, bruno cleanup, create user, send welcome mail, mailtrap --- projects/greenlight/cmd/api/helpers.go | 16 +++ projects/greenlight/cmd/api/main.go | 20 ++++ projects/greenlight/cmd/api/routes.go | 1 + projects/greenlight/cmd/api/users.go | 85 ++++++++++++++ .../{ => Movies}/Create Movie.yml | 0 .../{ => Movies}/Delete Movie.yml | 2 +- .../{ => Movies}/Get Movies.yml | 2 +- .../{ => Movies}/Get Single Movie.yml | 2 +- .../{ => Movies}/Update Movie.yml | 2 +- .../api/greenlight-bruno/Movies/folder.yml | 7 ++ .../greenlight-bruno/Users/Create User.yml | 23 ++++ .../api/greenlight-bruno/Users/folder.yml | 7 ++ projects/greenlight/go.mod | 5 + projects/greenlight/go.sum | 4 + projects/greenlight/internal/mailer/mailer.go | 106 ++++++++++++++++++ .../mailer/templates/user_welcome.tmpl | 33 ++++++ 16 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 projects/greenlight/cmd/api/users.go rename projects/greenlight/docs/api/greenlight-bruno/{ => Movies}/Create Movie.yml (100%) rename projects/greenlight/docs/api/greenlight-bruno/{ => Movies}/Delete Movie.yml (95%) rename projects/greenlight/docs/api/greenlight-bruno/{ => Movies}/Get Movies.yml (98%) rename projects/greenlight/docs/api/greenlight-bruno/{ => Movies}/Get Single Movie.yml (95%) rename projects/greenlight/docs/api/greenlight-bruno/{ => Movies}/Update Movie.yml (97%) create mode 100644 projects/greenlight/docs/api/greenlight-bruno/Movies/folder.yml create mode 100644 projects/greenlight/docs/api/greenlight-bruno/Users/Create User.yml create mode 100644 projects/greenlight/docs/api/greenlight-bruno/Users/folder.yml create mode 100644 projects/greenlight/internal/mailer/mailer.go create mode 100644 projects/greenlight/internal/mailer/templates/user_welcome.tmpl diff --git a/projects/greenlight/cmd/api/helpers.go b/projects/greenlight/cmd/api/helpers.go index 1cad132..8297d16 100644 --- a/projects/greenlight/cmd/api/helpers.go +++ b/projects/greenlight/cmd/api/helpers.go @@ -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() + }() +} diff --git a/projects/greenlight/cmd/api/main.go b/projects/greenlight/cmd/api/main.go index 9cb6763..4f12679 100644 --- a/projects/greenlight/cmd/api/main.go +++ b/projects/greenlight/cmd/api/main.go @@ -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 ", "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() diff --git a/projects/greenlight/cmd/api/routes.go b/projects/greenlight/cmd/api/routes.go index 6197867..9069e99 100644 --- a/projects/greenlight/cmd/api/routes.go +++ b/projects/greenlight/cmd/api/routes.go @@ -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)) diff --git a/projects/greenlight/cmd/api/users.go b/projects/greenlight/cmd/api/users.go new file mode 100644 index 0000000..1ec3151 --- /dev/null +++ b/projects/greenlight/cmd/api/users.go @@ -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) + } +} diff --git a/projects/greenlight/docs/api/greenlight-bruno/Create Movie.yml b/projects/greenlight/docs/api/greenlight-bruno/Movies/Create Movie.yml similarity index 100% rename from projects/greenlight/docs/api/greenlight-bruno/Create Movie.yml rename to projects/greenlight/docs/api/greenlight-bruno/Movies/Create Movie.yml diff --git a/projects/greenlight/docs/api/greenlight-bruno/Delete Movie.yml b/projects/greenlight/docs/api/greenlight-bruno/Movies/Delete Movie.yml similarity index 95% rename from projects/greenlight/docs/api/greenlight-bruno/Delete Movie.yml rename to projects/greenlight/docs/api/greenlight-bruno/Movies/Delete Movie.yml index 8fc3b2b..3b2969c 100644 --- a/projects/greenlight/docs/api/greenlight-bruno/Delete Movie.yml +++ b/projects/greenlight/docs/api/greenlight-bruno/Movies/Delete Movie.yml @@ -1,7 +1,7 @@ info: name: Delete Movie type: http - seq: 6 + seq: 3 http: method: DELETE diff --git a/projects/greenlight/docs/api/greenlight-bruno/Get Movies.yml b/projects/greenlight/docs/api/greenlight-bruno/Movies/Get Movies.yml similarity index 98% rename from projects/greenlight/docs/api/greenlight-bruno/Get Movies.yml rename to projects/greenlight/docs/api/greenlight-bruno/Movies/Get Movies.yml index 4267234..0ac0b1d 100644 --- a/projects/greenlight/docs/api/greenlight-bruno/Get Movies.yml +++ b/projects/greenlight/docs/api/greenlight-bruno/Movies/Get Movies.yml @@ -1,7 +1,7 @@ info: name: Get Movies type: http - seq: 3 + seq: 2 http: method: GET diff --git a/projects/greenlight/docs/api/greenlight-bruno/Get Single Movie.yml b/projects/greenlight/docs/api/greenlight-bruno/Movies/Get Single Movie.yml similarity index 95% rename from projects/greenlight/docs/api/greenlight-bruno/Get Single Movie.yml rename to projects/greenlight/docs/api/greenlight-bruno/Movies/Get Single Movie.yml index d92e8ad..22fe65a 100644 --- a/projects/greenlight/docs/api/greenlight-bruno/Get Single Movie.yml +++ b/projects/greenlight/docs/api/greenlight-bruno/Movies/Get Single Movie.yml @@ -1,7 +1,7 @@ info: name: Get Single Movie type: http - seq: 4 + seq: 2 http: method: GET diff --git a/projects/greenlight/docs/api/greenlight-bruno/Update Movie.yml b/projects/greenlight/docs/api/greenlight-bruno/Movies/Update Movie.yml similarity index 97% rename from projects/greenlight/docs/api/greenlight-bruno/Update Movie.yml rename to projects/greenlight/docs/api/greenlight-bruno/Movies/Update Movie.yml index 1f85eb3..ce0419a 100644 --- a/projects/greenlight/docs/api/greenlight-bruno/Update Movie.yml +++ b/projects/greenlight/docs/api/greenlight-bruno/Movies/Update Movie.yml @@ -1,7 +1,7 @@ info: name: Update Movie type: http - seq: 5 + seq: 4 http: method: PATCH diff --git a/projects/greenlight/docs/api/greenlight-bruno/Movies/folder.yml b/projects/greenlight/docs/api/greenlight-bruno/Movies/folder.yml new file mode 100644 index 0000000..41d1f2c --- /dev/null +++ b/projects/greenlight/docs/api/greenlight-bruno/Movies/folder.yml @@ -0,0 +1,7 @@ +info: + name: Movies + type: folder + seq: 3 + +request: + auth: inherit diff --git a/projects/greenlight/docs/api/greenlight-bruno/Users/Create User.yml b/projects/greenlight/docs/api/greenlight-bruno/Users/Create User.yml new file mode 100644 index 0000000..15e3e0d --- /dev/null +++ b/projects/greenlight/docs/api/greenlight-bruno/Users/Create User.yml @@ -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 diff --git a/projects/greenlight/docs/api/greenlight-bruno/Users/folder.yml b/projects/greenlight/docs/api/greenlight-bruno/Users/folder.yml new file mode 100644 index 0000000..1a959ee --- /dev/null +++ b/projects/greenlight/docs/api/greenlight-bruno/Users/folder.yml @@ -0,0 +1,7 @@ +info: + name: Users + type: folder + seq: 2 + +request: + auth: inherit diff --git a/projects/greenlight/go.mod b/projects/greenlight/go.mod index 4d2c305..1e0254a 100644 --- a/projects/greenlight/go.mod +++ b/projects/greenlight/go.mod @@ -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 +) diff --git a/projects/greenlight/go.sum b/projects/greenlight/go.sum index 9c96815..9d9d661 100644 --- a/projects/greenlight/go.sum +++ b/projects/greenlight/go.sum @@ -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= diff --git a/projects/greenlight/internal/mailer/mailer.go b/projects/greenlight/internal/mailer/mailer.go new file mode 100644 index 0000000..a58733b --- /dev/null +++ b/projects/greenlight/internal/mailer/mailer.go @@ -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 ` +// 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 "). +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 +} diff --git a/projects/greenlight/internal/mailer/templates/user_welcome.tmpl b/projects/greenlight/internal/mailer/templates/user_welcome.tmpl new file mode 100644 index 0000000..c422a0d --- /dev/null +++ b/projects/greenlight/internal/mailer/templates/user_welcome.tmpl @@ -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"}} + + + + + + + + + +

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}}