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

Add newsletter signup

You'll learn
  • Adding an email address form on the front page, where visitors can sign up for your new newsletter.
  • Handling and validating form input.
  • Testing email address validation and the newsletter signup handler.

So far, there's been little that visitors to your web app can actually do with it (except admire its beauty). Time to change that.

We will add a newsletter signup to the front page. You'll end up with:

  • A newsletter signup form on the front page of your app.
  • An HTTP handler that can handle the signup.
  • A database to store the newsletter signups.
  • Tests for your handler and the database functions, to make sure they work and keep working.
  • A way to send confirmation and welcome emails.

Because there's a lot of different components that we need to write, I will be splitting this up over a couple of sections. We will start by changing the front page and adding the HTTP handler. Let's get going. ๐Ÿ˜Ž

The code from this section can also be checked out with:

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

See the diff on Github.

Adding the form

Open up views/front.go and add the form, so the front page component looks like this:

views/front.go
package views import ( g "github.com/maragudk/gomponents" "github.com/maragudk/gomponents-heroicons/solid" . "github.com/maragudk/gomponents/html" ) func FrontPage() g.Node { return Page( "Canvas", "/", H1(g.Text(`Solutions to problems.`)), P(g.Raw(`Do you have problems? We also had problems.`)), P(g.Raw(`Then we created the <em>canvas</em> app, and now we don't! ๐Ÿ˜ฌ`)), H2(g.Text(`Do you want to know more?`)), P(g.Text(`Sign up to our newsletter below.`)), FormEl(Action("/newsletter/signup"), Method("post"), Class("flex items-center max-w-md"), Label(For("email"), Class("sr-only"), g.Text("Email")), Div(Class("relative rounded-md shadow-sm flex-grow"), Div(Class("absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"), solid.Mail(Class("h-5 w-5 text-gray-400")), ), Input(Type("email"), Name("email"), ID("email"), AutoComplete("email"), Required(), Placeholder("me@example.com"), TabIndex("1"), Class("focus:ring-gray-500 focus:border-gray-500 block w-full pl-10 text-sm border-gray-300 rounded-md")), ), Button(Type("submit"), g.Text("Sign up"), Class("ml-3 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")), ), ) }

If you ignore all the CSS classes that style the form, there's only a few things left. Let's try and remove the styling and other non-functional elements just so it's easier to read, and this is what's left:

FormEl(Action("/newsletter/signup"), Method("post")
	Input(Type("email"), Name("email"), AutoComplete("email"), Required()),
	Button(Type("submit"), g.Text("Sign up")),
)

As you can see, it's a form that makes an HTTP POST request to /newsletter/signup. There's a single required input field of both name and type email. There's also a hint to the browser that this can be auto-completed with the visitor's email address. That's it.

Small note: the form element is called FormEl in gomponents because there's also a form attribute in HTML, unsurprisingly called FormAttr in gomponents.

For the form styles to work correctly with TailwindCSS, add this with the other style links in views/page.go:

views/page.go
Link(Rel("stylesheet"), Href("https://unpkg.com/@tailwindcss/forms@0.3.3/dist/forms.min.css")),

Your front page should now look something like this:

A screenshot of the front page.

Let's move on to the handler that receives the form data.

The email signup handler

The HTTP handler that will receive the form data will end up doing this:

  • Get the email address from the form and validate that it's actually an email address.
  • Save the valid email address to the database.
  • Redirect to a "thank you" page.

Email address validation

Let's start with adding an Email type. I tend to create a model package to keep all my models in, so create a new file model/email.go and add this:

model/email.go
package model import ( "regexp" ) // emailAddressMatcher for valid email addresses. // See https://regex101.com/r/1BEPJo/latest for an interactive breakdown of the regexp. // See https://html.spec.whatwg.org/#valid-e-mail-address for the definition. var emailAddressMatcher = regexp.MustCompile( // Start of string `^` + // Local part of the address. Note that \x60 is a backtick (`) character. `(?P<local>[a-zA-Z0-9.!#$%&'*+/=?^_\x60{|}~-]+)` + `@` + // Domain of the address `(?P<domain>[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)` + // End of string `$`, ) type Email string func (e Email) IsValid() bool { return emailAddressMatcher.MatchString(string(e)) } func (e Email) String() string { return string(e) }

Email is basically just a string with an IsValid method on it, that validates the email address according to the emailAddressMatcher regular expression. I won't go into details with how exactly it validates the email. If you're interested, play around with the regular expression on regex101.com.

Bonus: regex101.com is great!

To make sure that something as important as email address validation works correctly, let's add a test in model/email_test.go:

model/email_test.go
package model_test import ( "testing" "github.com/matryer/is" "canvas/model" ) func TestEmail_IsValid(t *testing.T) { tests := []struct { address string valid bool }{ {"me@example.com", true}, {"@example.com", false}, {"me@", false}, {"@", false}, {"", false}, } t.Run("reports valid email addresses", func(t *testing.T) { for _, test := range tests { t.Run(test.address, func(t *testing.T) { is := is.New(t) e := model.Email(test.address) is.Equal(test.valid, e.IsValid()) }) } }) }

For bonus points, add a couple of extra items in the tests slice, and be surprised by how many strings are actually valid email addresses. ๐Ÿ˜„

The actual handler

The handler NewsletterSignup takes our mux as well as something that implements the signupper interface, in handlers/newsletter.go:

handlers/newsletter.go
package handlers import ( "context" "net/http" "github.com/go-chi/chi/v5" "canvas/model" "canvas/views" ) type signupper interface { SignupForNewsletter(ctx context.Context, email model.Email) (string, error) } func NewsletterSignup(mux chi.Router, s signupper) { mux.Post("/newsletter/signup", func(w http.ResponseWriter, r *http.Request) { email := model.Email(r.FormValue("email")) if !email.IsValid() { http.Error(w, "email is invalid", http.StatusBadRequest) return } if _, err := s.SignupForNewsletter(r.Context(), email); err != nil { http.Error(w, "error signing up, refresh to try again", http.StatusBadGateway) return } http.Redirect(w, r, "/newsletter/thanks", http.StatusFound) }) } func NewsletterThanks(mux chi.Router) { mux.Get("/newsletter/thanks", func(w http.ResponseWriter, r *http.Request) { _ = views.NewsletterThanksPage("/newsletter/thanks").Render(w) }) }

What's the signupper interface? It's a small interface with just one method, SignupForNewsletter, and it enables decoupling our HTTP handler from the database implementation that we will write later. This makes it both extremely easy to figure out what this handler depends on, and makes it really easy to test as well. The handler doesn't know about any database methods we will add later, just this one. Perfect.

r.FormValue parses the request form data if it isn't parsed already, and returns the field identified by the name parameter. We pass its return value directly to our Email model, so we can validate it right after.

Because a lot of the heavy lifting of validation is done by the browser (that's what the required and type="email" attributes on the input element in the form are for), we will just return a very raw error page for now.

After that, we call SignupForNewsletter and check for errors. We ignore the string return value for now (we'll get back to what it is later) and will also just send a raw error page. The error pages will be improved in a later section.

Finally we redirect to /newsletter/thanks using a HTTP 302 Found status code. This is a common pattern after a POST request. If we rendered a view directly, a visitor refreshing the page would resend the form. With the redirect, that doesn't happen.

The NewsletterThanks just registers the route and displays a thank you page, which you should define in views/newsletter.go:

views/newsletter.go
package views import ( g "github.com/maragudk/gomponents" . "github.com/maragudk/gomponents/html" ) func NewsletterThanksPage(path string) g.Node { return Page( "Thanks for signing up!", path, H1(g.Text(`Thanks for signing up!`)), P(g.Raw(`Now check your inbox (or spam folder) for a confirmation link. ๐Ÿ˜Š`)), ) }

Testing the handler

I consider the signup handler important enough to test, so we will test both that it accepts and saves valid email addresses, and rejects invalid ones.

The way we test it is very similar to how we tested our health handler. The new part is that we make a POST request instead of a GET request (and use a new test helper makePostRequest), and also that we use a mock that satisfies the signupper interface from before. Add this to handlers/newsletter_test.go:

handlers/newsletter_test.go
package handlers_test import ( "context" "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/go-chi/chi/v5" "github.com/matryer/is" "canvas/handlers" "canvas/model" ) type signupperMock struct { email model.Email } func (s *signupperMock) SignupForNewsletter(ctx context.Context, email model.Email) (string, error) { s.email = email return "", nil } func TestNewsletterSignup(t *testing.T) { mux := chi.NewMux() s := &signupperMock{} handlers.NewsletterSignup(mux, s) t.Run("signs up a valid email address", func(t *testing.T) { is := is.New(t) code, _, _ := makePostRequest(mux, "/newsletter/signup", createFormHeader(), strings.NewReader("email=me%40example.com")) is.Equal(http.StatusFound, code) is.Equal(model.Email("me@example.com"), s.email) }) t.Run("rejects an invalid email address", func(t *testing.T) { is := is.New(t) code, _, _ := makePostRequest(mux, "/newsletter/signup", createFormHeader(), strings.NewReader("email=notanemail")) is.Equal(http.StatusBadRequest, code) }) } // makePostRequest and returns the status code, response header, and the body. func makePostRequest(handler http.Handler, target string, header http.Header, body io.Reader) (int, http.Header, string) { req := httptest.NewRequest(http.MethodPost, target, body) req.Header = header res := httptest.NewRecorder() handler.ServeHTTP(res, req) result := res.Result() bodyBytes, err := io.ReadAll(result.Body) if err != nil { panic(err) } return result.StatusCode, result.Header, string(bodyBytes) } func createFormHeader() http.Header { header := http.Header{} header.Set("Content-Type", "application/x-www-form-urlencoded") return header }

The important part is that we check the response status code, and that the signupperMock got the correctly parsed email address.

Adding the route

The last part is adding the two new handlers to our server/routes.go file. Because we don't have the database part implemented yet, we just pass a temporary mock to the signup handler:

server/routes.go
package server import ( "context" "canvas/handlers" "canvas/model" ) func (s *Server) setupRoutes() { handlers.Health(s.mux) handlers.FrontPage(s.mux) handlers.NewsletterSignup(s.mux, &signupperMock{}) handlers.NewsletterThanks(s.mux) } type signupperMock struct{} func (s signupperMock) SignupForNewsletter(ctx context.Context, email model.Email) (string, error) { return "", nil }

Test and start

Finally, make sure to run your tests and check that everything works as expected. Also, start the server, open up your browser at localhost:8080 and play around with the signup form. See what happens when you don't enter an email address, when it's invalid, and when it's valid.

Then stretch a little, go eat a nice snack, and come back for the next section: implementing the database functions for newsletter signup.

Review questions

Sign up or log in to get review questions by email! ๐Ÿ“ง

Questions?

Get help on Twitter or by email.