This course is still being built. Content will change. Get updates on the mailing list.

Confirm newsletter signups

You'll learn
  • Confirming the newsletter email address associated with a token.
  • Sending a newsletter welcome email.

We now have components that let our visitors sign up to the newsletter, and receive a confirmation email with a link they have to click. In this section, we will implement the handler behind that link.

The flow will be like this:

  1. We will handle a GET request to /newsletter/confirm?token=123 by showing a view with a button. The visitor has to click this button to finally confirm the subscription.
  2. A POST request to the same path marks the email address associated with the token as confirmed, and sends a message to our job queue to send out a welcome email. The visitor is then redirected to a final welcome page.
  3. Our job runner picks up the message and runs a job to send out the welcome email.

Why do we need the first page, where the user has to click another confirmation button? Isn't it best to minimize the number of interactions the user has to do?

Yes, that's generally best. But the HTTP specification says that a GET request should be "safe", meaning that it should not have side effects, such as changing state in a database. Some email clients rely on this, and for example prefetch any links in emails. Other times, antivirus software does the same, to check what's on a web page before the user has a chance to click on a potentially malicious link.

That means that if we rely on a GET request alone to confirm a subscription, someone else than the newsletter recipient could sign an email address up for the newsletter, and it could be confirmed by this (spec compliant) behavior. Not good.

But otherwise, that's all there is to it. This will be a quick section, with some code related to the new functionality, but not so much text. That's a good thing! It means our app foundation is starting to be pretty solid, and that our infrastructure components such as the Emailer, the Queue, and the job Runner have the right design. Let's get started!

As always:

$ git fetch && git checkout --track golangdk/newsletter-confirm

See the diff on Github.

The handlers

Open up handlers/newsletter.go and we'll add all three handlers at the same time, because they're so related:

package handlers // … type confirmer interface { ConfirmNewsletterSignup(ctx context.Context, token string) (*model.Email, error) } func NewsletterConfirm(mux chi.Router, s confirmer, q sender) { mux.Get("/newsletter/confirm", func(w http.ResponseWriter, r *http.Request) { token := r.FormValue("token") _ = views.NewsletterConfirmPage("/newsletter/confirm", token).Render(w) }) mux.Post("/newsletter/confirm", func(w http.ResponseWriter, r *http.Request) { token := r.FormValue("token") email, err := s.ConfirmNewsletterSignup(r.Context(), token) if err != nil { http.Error(w, "error saving email address confirmation, refresh to try again", http.StatusBadGateway) return } if email == nil { http.Error(w, "bad token", http.StatusBadRequest) return } err = q.Send(r.Context(), model.Message{ "job": "welcome_email", "email": email.String(), }) if err != nil { http.Error(w, "error saving email address confirmation, refresh to try again", http.StatusBadGateway) return } http.Redirect(w, r, "/newsletter/confirmed", http.StatusFound) }) } func NewsletterConfirmed(mux chi.Router) { mux.Get("/newsletter/confirmed", func(w http.ResponseWriter, r *http.Request) { _ = views.NewsletterConfirmedPage("/newsletter/confirmed").Render(w) }) }

The NewsletterConfirm function sets up both the GET and POST handlers for the route /newsletter/confirm.

The GET request handler takes the token from the URL and passes it on to the view to add to an HTML form, and nothing more.

The POST request handler is quite similar to the signup handler above it. We call ConfirmNewsletterSignup on the confirmer interface, which returns the email address associated with the token if successful. That email address is passed to a message along with the welcome_email job name, and we redirect to /newsletter/confirmed afterwards. We're using the same POST-then-GET pattern as seen previously, so we avoid form resubmission on page refresh. That could trigger another welcome email, and this way, we avoid that problem entirely.

NewsletterConfirmed just registers the /newsletter/confirmed route and displays a static view.

The views

The associated views go into views/newsletter.go:

package views // … func NewsletterConfirmPage(path string, token string) g.Node { return Page( "Confirm your newsletter subscription", path, H1(g.Text(`Confirm your newsletter subscription`)), P(g.Text(`Press the big button below to confirm your subscription.`)), FormEl(Action("/newsletter/confirm"), Method("post"), Input(Type("hidden"), Name("token"), Value(token)), Button(Type("submit"), g.Text("Confirm"), Class("inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 flex-none")), ), ) } func NewsletterConfirmedPage(path string) g.Node { return Page( "Newsletter subscription confirmed", path, H1(g.Text(`Newsletter subscription confirmed`)), P(g.Textf(`You will now receive the newsletter. 😎`)), ) }

The only slightly interesting thing here is that the token passed to the NewsletterConfirmPage view component is put directly into the HTML form as a hidden input field, forwarding it to the POST handler.

Testing the handlers

Open up handlers/newsletter_test.go and add a mock for the confirmer interface and a test for the POST handler:

package handlers_test // … type confirmerMock struct { token string } func (c *confirmerMock) ConfirmNewsletterSignup(ctx context.Context, token string) (*model.Email, error) { c.token = token email := model.Email("") return &email, nil } func TestNewsletterConfirm(t *testing.T) { t.Run("confirms the newsletter signup and sends a message", func(t *testing.T) { is := is.New(t) mux := chi.NewMux() c := &confirmerMock{} q := &senderMock{} handlers.NewsletterConfirm(mux, c, q) code, _, _ := makePostRequest(mux, "/newsletter/confirm", createFormHeader(), strings.NewReader("token=123")) is.Equal(http.StatusFound, code) is.Equal("123", c.token) is.Equal(q.m, model.Message{ "job": "welcome_email", "email": "", }) }) } // …

The test is basically the same as TestNewsletterSignup above it. We test that it redirects, calls the confirmerMock, and adds a message to our job queue.

The database confirmation code

With the handlers in place, it's time to write the query that sets the confirmed flag in the newsletter_subscribers table, where the token is the one we got from the handler.

Open up storage/newsletter.go and add:

package storage import ( "context" "crypto/rand" "database/sql" "errors" "fmt" "canvas/model" ) // … // ConfirmNewsletterSignup with the given token. Returns the associated email if matched. func (d *Database) ConfirmNewsletterSignup(ctx context.Context, token string) (*model.Email, error) { var email model.Email query := ` update newsletter_subscribers set confirmed = true where token = $1 returning email` err := d.DB.GetContext(ctx, &email, query, token) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, err } return &email, nil } // …

The query is a standard SQL update query, apart from the last returning clause, which is a Postgres feature. With it, we can return the value of one or more columns of the updated row.

In the error check, we first check if the error is the sql.ErrNoRows sentinel error. (A sentinel error is just a package-level variable we can compare returned errors with.) If it is, we return no error but only a nil value.

The companion tests are equally short. We check that the confirmed column is indeed set for the row with the token, and that nil is returned if the given token is not found.

package storage_test // … func TestDatabase_ConfirmNewsletterSignup(t *testing.T) { integrationtest.SkipIfShort(t) t.Run("confirms subscriber from the token and returns the associated email address", func(t *testing.T) { is := is.New(t) db, cleanup := integrationtest.CreateDatabase() defer cleanup() token, err := db.SignupForNewsletter(context.Background(), "") is.NoErr(err) var confirmed bool err = db.DB.Get(&confirmed, `select confirmed from newsletter_subscribers where token = $1`, token) is.NoErr(err) is.True(!confirmed) email, err := db.ConfirmNewsletterSignup(context.Background(), token) is.NoErr(err) is.Equal("", email.String()) err = db.DB.Get(&confirmed, `select confirmed from newsletter_subscribers where token = $1`, token) is.NoErr(err) is.True(confirmed) }) t.Run("returns nil if no such token", func(t *testing.T) { is := is.New(t) db, cleanup := integrationtest.CreateDatabase() defer cleanup() _, err := db.SignupForNewsletter(context.Background(), "") is.NoErr(err) email, err := db.ConfirmNewsletterSignup(context.Background(), "notmytoken") is.NoErr(err) is.True(email == nil) }) }

Routing to the handlers

We can now add our new handlers to the routes in the server:

package server import ( "canvas/handlers" ) func (s *Server) setupRoutes() { handlers.Health(s.mux, s.database) handlers.FrontPage(s.mux) handlers.NewsletterSignup(s.mux, s.database, s.queue) handlers.NewsletterThanks(s.mux) handlers.NewsletterConfirm(s.mux, s.database, s.queue) handlers.NewsletterConfirmed(s.mux) }

Try starting up the server now. You can sign up with an email address, and check your Postmark sandbox for the confirmation link in the email. If you visit the link in your browser and click the confirm button, you should be redirected to the last welcome page.

A screenshot of the confirmation page.A screenshot of the welcome page.

But now, your job runner will be complaining in the logs:

2021-11-27T05:13:48Z    INFO    jobs/runner.go:87       No job with this name   {"release": "", "name": "welcome_email"}

Let's fix that.

Adding the welcome email job

Because the job runner is already in place, adding a new job is as easy as writing the code for it and registering it. So that's what we'll do. Open up jobs/email.go and add:

package jobs // … type newsletterWelcomeEmailSender interface { SendNewsletterWelcomeEmail(ctx context.Context, to model.Email) error } func SendNewsletterWelcomeEmail(r registry, es newsletterWelcomeEmailSender) { r.Register("welcome_email", func(ctx context.Context, m model.Message) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() to, ok := m["email"] if !ok { return errors.New("no email address in message") } if err := es.SendNewsletterWelcomeEmail(ctx, model.Email(to)); err != nil { return fmt.Errorf("error sending newsletter welcome email: %w", err) } return nil }) }

Similar to the confirmation emailer, we use the newsletterWelcomeEmailSender interface to send an email to the address given in the message. That's it. The accompanying tests:

package jobs_test // … type mockWelcomeEmailer struct { err error to model.Email } func (m *mockWelcomeEmailer) SendNewsletterWelcomeEmail(ctx context.Context, to model.Email) error { = to return m.err } func TestSendNewsletterWelcomeEmail(t *testing.T) { r := testRegistry{} t.Run("passes the recipient email to the email sender", func(t *testing.T) { is := is.New(t) emailer := &mockWelcomeEmailer{} jobs.SendNewsletterWelcomeEmail(r, emailer) job, ok := r["welcome_email"] is.True(ok) err := job(context.Background(), model.Message{"email": ""}) is.NoErr(err) is.Equal("", }) t.Run("errors on email sending failure", func(t *testing.T) { is := is.New(t) emailer := &mockWelcomeEmailer{err: errors.New("email server down")} jobs.SendNewsletterWelcomeEmail(r, emailer) job := r["welcome_email"] err := job(context.Background(), model.Message{"email": ""}) is.True(err != nil) }) }

No surprises here. We test that the job reacts to the right job name, and passes the correct email address to our mockWelcomeEmailer.

Finally, registering the job:

package jobs func (r *Runner) registerJobs() { SendNewsletterConfirmationEmail(r, r.emailer) SendNewsletterWelcomeEmail(r, r.emailer) }

The welcome email

The very last thing we need is adding the SendNewsletterWelcomeEmail method to the Emailer. We're going to treat this as a marketing email, because it's the first email from the newsletter that people will receive. In the Postmark API, this means using a different message stream, which is just a different HTTP header value expressed through the marketingMessageStream constant.

package messaging // … // SendNewsletterWelcomeEmail with just the web app URL. func (e *Emailer) SendNewsletterWelcomeEmail(ctx context.Context, to model.Email) error { keywords := map[string]string{ "base_url": e.baseURL, } return e.send(ctx, requestBody{ MessageStream: marketingMessageStream, From: e.marketingFrom, To: to.String(), Subject: "Welcome to the Canvas newsletter", HtmlBody: getEmail("welcome_email.html", keywords), TextBody: getEmail("welcome_email.txt", keywords), }) } // …

And the text and HTML emails to go with it:

Welcome to the Canvas newsletter. We hope you will enjoy it! You can always visit us at {{base_url}}. Canvas Some Street Earth
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" ""> <html xmlns=""> <head> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="x-apple-disable-message-reformatting" /> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <meta name="color-scheme" content="light dark" /> <meta name="supported-color-schemes" content="light dark" /> <title></title> <style type="text/css" rel="stylesheet" media="all"> /* Base ------------------------------ */ body { width: 100% !important; height: 100%; margin: 0; -webkit-text-size-adjust: none; } a { color: #3869D4; } a img { border: none; } td { word-break: break-word; } .preheader { display: none !important; visibility: hidden; mso-hide: all; font-size: 1px; line-height: 1px; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; } /* Type ------------------------------ */ body, td, th { font-family: "Nunito Sans", Helvetica, Arial, sans-serif; } h1 { margin-top: 0; color: #333333; font-size: 22px; font-weight: bold; text-align: left; } h2 { margin-top: 0; color: #333333; font-size: 16px; font-weight: bold; text-align: left; } h3 { margin-top: 0; color: #333333; font-size: 14px; font-weight: bold; text-align: left; } td, th { font-size: 16px; } p, ul, ol, blockquote { margin: .4em 0 1.1875em; font-size: 16px; line-height: 1.625; } p.sub { font-size: 13px; } /* Utilities ------------------------------ */ .align-right { text-align: right; } .align-left { text-align: left; } .align-center { text-align: center; } /* Buttons ------------------------------ */ .button { background-color: #3869D4; border-top: 10px solid #3869D4; border-right: 18px solid #3869D4; border-bottom: 10px solid #3869D4; border-left: 18px solid #3869D4; display: inline-block; color: #FFF; text-decoration: none; border-radius: 3px; box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16); -webkit-text-size-adjust: none; box-sizing: border-box; } @media only screen and (max-width: 500px) { .button { width: 100% !important; text-align: center !important; } } body { background-color: #FFF; color: #333; } p { color: #333; } .email-wrapper { width: 100%; margin: 0; padding: 0; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; } .email-content { width: 100%; margin: 0; padding: 0; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; } /* Masthead ----------------------- */ .email-masthead { padding: 25px 0; text-align: center; } .email-masthead_name { font-size: 16px; font-weight: bold; color: #A8AAAF; text-decoration: none; text-shadow: 0 1px 0 white; } /* Body ------------------------------ */ .email-body { width: 100%; margin: 0; padding: 0; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; } .email-body_inner { width: 570px; margin: 0 auto; padding: 0; -premailer-width: 570px; -premailer-cellpadding: 0; -premailer-cellspacing: 0; } .email-footer { width: 570px; margin: 0 auto; padding: 0; -premailer-width: 570px; -premailer-cellpadding: 0; -premailer-cellspacing: 0; text-align: center; } .email-footer p { color: #A8AAAF; } .body-action { width: 100%; margin: 30px auto; padding: 0; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; text-align: center; } .body-sub { margin-top: 25px; padding-top: 25px; border-top: 1px solid #EAEAEC; } .content-cell { padding: 35px; } /*Media Queries ------------------------------ */ @media only screen and (max-width: 600px) { .email-body_inner, .email-footer { width: 100% !important; } } @media (prefers-color-scheme: dark) { body { background-color: #333333 !important; color: #FFF !important; } p, ul, ol, blockquote, h1, h2, h3, span { color: #FFF !important; } .email-masthead_name { text-shadow: none !important; } } :root { color-scheme: light dark; supported-color-schemes: light dark; } </style> <!--[if mso]> <style type="text/css"> .f-fallback { font-family: Arial, sans-serif; } </style> <![endif]--> </head> <body> <span class="preheader">Confirm your subscription to the Canvas newsletter.</span> <table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation"> <tr> <td align="center"> <table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation"> <tr> <td class="email-masthead"> <a href="{{base_url}}" class="f-fallback email-masthead_name"> Canvas </a> </td> </tr> <!-- Email Body --> <tr> <td class="email-body" width="570" cellpadding="0" cellspacing="0"> <table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation"> <!-- Body content --> <tr> <td class="content-cell"> <div class="f-fallback"> <h1>Welcome!</h1> <p>Welcome to the Canvas newsletter. We hope you will enjoy it!</p> <p>You can always visit us at <a href="{{base_url}}">our website</a>.</p> </div> </td> </tr> </table> </td> </tr> <tr> <td> <table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation"> <tr> <td class="content-cell" align="center"> <p class="f-fallback sub align-center"> Canvas <br>Some Street <br>Earth </p> </td> </tr> </table> </td> </tr> </table> </td> </tr> </table> </body> </html>

Does it work?

Now restart your app. If you had a message waiting in the job queue about sending the welcome email, it will now be received by the job and sent to Postmark. If not, try signing up and confirming the newsletter, and see that everything works as expected.

If the welcome email shows up in your Postmark account, well done! (If not, don't panic. Run your tests and start debugging. 🐛) Next up: let's make this live and deploy our newsletter functionality to the cloud. ✉️☁️

Review questions

Sign up or log in to get review questions by email! 📧


Get help on Twitter or by email.