Sending a welcome email
Now that we have the content for the welcome email written, let’s create the code to send it.
To send emails we could use Go’s net/smtp package from the standard library. But unfortunately it’s been frozen for a few years, and doesn’t support some of the features that you might need in more advanced use cases, such as adding attachments.
So instead, I recommend using the third-party wneessen/go-mail package to help send emails. It’s well tested, has good documentation, and a very clear and usable API.
If you’re coding along, please use go get to download the latest release of this package:
$ go get github.com/wneessen/go-mail@latest go: added github.com/wneessen/go-mail v0.6.2
Creating an email helper
Rather than writing all the code for sending the welcome email in our registerUserHandler, in this chapter we’re going to create a new internal/mailer package which wraps up the logic for parsing our email templates and sending emails.
In addition to that, we’re also going to use Go’s embedded files functionality, so that the email template files will be built into our binary when we create it later. This is really nice because it means we won’t have to deploy these template files separately to our production server.
It’s probably easiest to demonstrate how this works by getting straight into the code and talking through the details as we go.
Let’s begin by creating a new internal/mailer/mailer.go file:
$ touch internal/mailer/mailer.go
And then go ahead and add the following code:
package mailer import ( "bytes" "embed" "time" "github.com/wneessen/go-mail" // Import the html/template and text/template packages. Because these share the same // package name ("template") we need to disambiguate them and alias them to ht and tt // respectively. ht "html/template" tt "text/template" ) // 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.Client 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 { client *mail.Client sender string } func New(host string, port int, username, password, sender string) (*Mailer, error) { // 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. I've // split the NewClient arguments over multiple lines for readability, but you can // make this a single line if you prefer. client, err := mail.NewClient( host, mail.WithSMTPAuth(mail.SMTPAuthLogin), mail.WithPort(port), mail.WithUsername(username), mail.WithPassword(password), mail.WithTimeout(5*time.Second), ) if err != nil { return nil, err } // Return a Mailer instance containing the client and sender information. mailer := &Mailer{ client: client, sender: sender, } return mailer, nil } // 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 any parameter. func (m *Mailer) Send(recipient string, templateFile string, data any) error { // Use the ParseFS() method from text/template to parse the required template file // from the embedded file system. textTmpl, err := tt.New("").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 = textTmpl.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 = textTmpl.ExecuteTemplate(plainBody, "plainBody", data) if err != nil { return err } // Use the ParseFS() method from html/template this time to parse the required template // file from the embedded file system. htmlTmpl, err := ht.New("").ParseFS(templateFS, "templates/"+templateFile) if err != nil { return err } // And execute the "htmlBody" template and store the result in the htmlBody variable. htmlBody := new(bytes.Buffer) err = htmlTmpl.ExecuteTemplate(htmlBody, "htmlBody", data) if err != nil { return err } // Use the mail.NewMsg() function to initialize a new mail.Msg instance. // Then we use the To(), From() and Subject() methods to set the email recipient, // sender and subject headers, the SetBodyString() method to set the plain-text body, // and the AddAlternativeString() method to set the HTML body. msg := mail.NewMsg() err = msg.To(recipient) if err != nil { return err } err = msg.From(m.sender) if err != nil { return err } msg.Subject(subject.String()) msg.SetBodyString(mail.TypeTextPlain, plainBody.String()) msg.AddAlternativeString(mail.TypeTextHTML, 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. return m.client.DialAndSend(msg) }
Using embedded file systems
Before we continue, let’s take a quick moment to discuss embedded file systems in more detail, because there are a couple of things that can be confusing when you first encounter them.
You can only use the
//go:embeddirective on global variables at package level, not within functions or methods. If you try to use it in a function or method, you’ll get the error"go:embed cannot apply to var inside func"at compile time.When you use the directive
//go:embed "<path>"to create an embedded file system, the path should be relative to the source code file containing the directive. So in our case,//go:embed "templates"embeds the contents of the directory atinternal/mailer/templates.The embedded file system is rooted in the directory which contains the
//go:embeddirective. So, in our case, to get theuser_welcome.tmplfile we need to retrieve it fromtemplates/user_welcome.tmplin the embedded file system.Paths cannot contain
.or..elements, nor may they begin or end with a/. This essentially restricts you to only embedding files that are contained within the same directory (or a subdirectory) as the source code which has the//go:embeddirective.If the path is for a directory, then all files in the directory are recursively embedded, except for files with names that begin with
.or_. If you want to include these files you should use the*wildcard character in the path, like//go:embed "templates/*"You can specify multiple directories and files in one directive. For example:
//go:embed "images" "styles/css" "favicon.ico".The path separator should always be a forward slash, even on Windows machines.
Using our mail helper
Now that our email helper package is in place, we need to hook it up to the rest of our code in the cmd/api/main.go file. Specifically, we need to do two things:
- Adapt our code to accept the configuration settings for the SMTP server as command-line flags.
- Initialize a new
Mailerinstance and make it available to our handlers via theapplicationstruct.
If you’re following along, please make sure to use your own Mailtrap SMTP server settings from the previous chapter as the default values for the command-line flags here — not the exact values I’m using in the code below.
package main import ( "context" "database/sql" "flag" "log/slog" "os" "time" "greenlight.alexedwards.net/internal/data" "greenlight.alexedwards.net/internal/mailer" // New import _ "github.com/lib/pq" ) const version = "1.0.0" // Update the config struct to hold the SMTP server settings. type config struct { port int env string db struct { dsn string maxOpenConns int maxIdleConns int maxIdleTime time.Duration } limiter struct { enabled bool rps float64 burst int } smtp struct { host string port int username string password string sender string } } // Update the application struct to hold a pointer to a new Mailer instance. type application struct { config config logger *slog.Logger models data.Models mailer *mailer.Mailer } func main() { var cfg config flag.IntVar(&cfg.port, "port", 4000, "API server port") flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)") flag.StringVar(&cfg.db.dsn, "db-dsn", os.Getenv("GREENLIGHT_DB_DSN"), "PostgreSQL DSN") flag.IntVar(&cfg.db.maxOpenConns, "db-max-open-conns", 25, "PostgreSQL max open connections") flag.IntVar(&cfg.db.maxIdleConns, "db-max-idle-conns", 25, "PostgreSQL max idle connections") flag.DurationVar(&cfg.db.maxIdleTime, "db-max-idle-time", 15*time.Minute, "PostgreSQL max connection idle time") flag.BoolVar(&cfg.limiter.enabled, "limiter-enabled", true, "Enable rate limiter") flag.Float64Var(&cfg.limiter.rps, "limiter-rps", 2, "Rate limiter maximum requests per second") flag.IntVar(&cfg.limiter.burst, "limiter-burst", 4, "Rate limiter maximum burst") // 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", "a7420fc0883489", "SMTP username") flag.StringVar(&cfg.smtp.password, "smtp-password", "e75ffd0a3aa5ec", "SMTP password") flag.StringVar(&cfg.smtp.sender, "smtp-sender", "Greenlight <no-reply@greenlight.alexedwards.net>", "SMTP sender") flag.Parse() logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) db, err := openDB(cfg) if err != nil { logger.Error(err.Error()) os.Exit(1) } defer db.Close() logger.Info("database connection pool established") // Initialize a new Mailer instance using the settings from the command line // flags. mailer, err := mailer.New(cfg.smtp.host, cfg.smtp.port, cfg.smtp.username, cfg.smtp.password, cfg.smtp.sender) if err != nil { logger.Error(err.Error()) os.Exit(1) } // And add it to the application struct. app := &application{ config: cfg, logger: logger, models: data.NewModels(db), mailer: mailer, } err = app.serve() if err != nil { logger.Error(err.Error()) os.Exit(1) } } ...
And then the final thing we need to do is update our registerUserHandler to actually send the email, which we can do like so:
package main ... func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) { ... // Nothing above here needs to change. // Call the Send() method on our Mailer, passing in the user's email address, // name of the template file, and the User struct containing the new user's data. err = app.mailer.Send(user.Email, "user_welcome.tmpl", user) if err != nil { app.serverErrorResponse(w, r, err) return } err = app.writeJSON(w, http.StatusCreated, envelope{"user": user}, nil) if err != nil { app.serverErrorResponse(w, r, err) } }
Alright, let’s try this out!
Run the application, then use curl in another terminal window to register a brand-new user with the email address bob@example.com:
$ BODY='{"name": "Bob Jones", "email": "bob@example.com", "password": "pa55word"}'
$ curl -w '\nTime: %{time_total}\n' -d "$BODY" localhost:4000/v1/users
{
"user": {
"id": 3,
"created_at": "2021-04-11T20:26:22+02:00",
"name": "Bob Jones",
"email": "bob@example.com",
"activated": false
}
}
Time: 2.331957
If everything is set up correctly you should get a 201 Created response containing the new user details, similar to the response above.
Checking the email in Mailtrap
If you’ve been following along and are using the Mailtrap SMTP server credentials, when you go back to your account you should now see the welcome email for bob@example.com in your Demo inbox, like so:
If you want, you can also click on the Text tab to see the plain-text version of the email, and the Raw tab to see the complete email including headers.
Wrapping this up, we covered quite a lot of ground in this chapter. But the nice thing about the pattern we’ve built is that it’s easy to extend. If we want to send other emails from our application in the future, we can simply make an additional file in our internal/mailer/templates folder with the email content, and then send it from our handlers in the same way that we have done here.
Additional information
Retrying email send attempts
If you want, you can make the email sending process a bit more robust by adding some basic ‘retry’ functionality to the Mailer.Send() method. For example:
func (m Mailer) Send(recipient, templateFile string, data any) 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.client.DialAndSend(msg) if err == nil { return nil } // If it didn't work, sleep for a short time and retry. if i != 3 { time.Sleep(500 * time.Millisecond) } } return err }
This retry functionality is a relatively simple addition to our code, but it helps to increase the probability that emails are successfully sent in the event of transient network issues. If you’re sending emails in a background process (like we will do in the next chapter), you might want to make the sleep duration even longer here, as it won’t materially impact the client and gives more time for transient issues to resolve.