Build a newsletter Form and Backend: create a form, use Airtable as a backend to store signups and send bulk emails
I recommend to keep those resources of mine
at hand while following the course:
Introduction to the project
The goal of this project is to create a fully working newsletter software.
You might be familiar with ConvertKit, Mailchimp or another newsletter software. Either because you used it to send a newsletter, or because you receive newsletters from other people.
We’ll first build a simple form, people that come to our site at /
will see 2 input fields to collect name and email.
We’ll then create an administration interface. From there you can see the subscribers, delete subscribers, and compose / send an email to them.
The list of email subscribers will be stored on Airtable, a sort of database / spreadsheet service with a great API.
Let’s start!
Create the form
Let’s start by creating the form. We use Express to simplify managing the Web Server.
Go to Glitch and create a new Express project: Glitch :・゚✧
The sample project is a working simple application, we’ll tweak that to provide our own functionality.
Open the views/index.html
file.
Remove all the existing content of the main
tag:
We are going to provide our own HTML:
<main>
<h1>Join my list</h1>
<form>
<input name="name" placeholder="Name" />
<input name="email" placeholder="Email" type="email" />
<input type="submit">
</form>
<p>I'll send you an email every week. No spam ever!</p>
</main>
Simple enough, right?
In the head
tag of the document we can see it includes a CSS file, and a JavaScript file. We’ll need both, so we can leave them there, but we change the title
tag and the meta
description to “Join my list”.
Here’s the full HTML code at the moment:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Join my list</title>
<meta name="description" content="Join my list">
<link id="favicon" rel="icon" href="https://glitch.com/edit/favicon-app.ico" type="image/x-icon">
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- import the webpage's stylesheet -->
<link rel="stylesheet" href="/style.css">
<!-- import the webpage's client-side javascript file -->
<script src="/client.js" defer></script>
</head>
<body>
<main>
<h1>Join my list</h1>
<form>
<input name="name" placeholder="Name" />
<input name="email" placeholder="Email" type="email" />
<input type="submit" value="Join" >
</form>
<p>I'll send you an email every week. No spam ever!</p>
</main>
</body>
</html>
Next, let’s wipe the contents of the public/client.js
and public/style.css
files.
Let’s start with the CSS. The form now is really poorly looking:
We’ll center the text, change the font and style the form input elements better. After all, it must be an attractive form otherwise people will think we’re not serious enough.
I certainly would not type my data in there, would you?
Add this CSS:
body {
text-align: center;
font-family: system-ui;
background-color: #ffe4e1;
}
main {
background-color: white;
padding-top: 50px;
padding-bottom: 80px;
max-width: 500px;
margin: 0 auto;
margin-top: 100px;
}
input {
padding: 10px;
}
input[type=submit] {
padding: 10px 20px;
background: white;
border: 1px solid lightgray;
cursor: pointer;
}
input[type=submit]:hover {
background: gray;
color: white;
}
This is the result:
Much better looking, right? I’m no designer! But if you are, feel free to style this form as you like.
If you click the Submit button now, the page simply reloads because the browser by default sends the data in the form to the same URL using the GET HTTP method, and our server will simply serve again the page.
In the next lesson we’ll make this form do something useful, with the help of a little bit of JavaScript.
Project link: https://glitch.com/edit/#!/node-course-project-newsletter-a
Use JavaScript to intercept form data and send it to Node
With the form nicely styled we are ready to do something with it.
We will intercept the submit event attached to the form, grab the data and send it to our backend. This is done with plain frontend JavaScript. Bear with me, I’ll keep frontend to a minimum. Node.js will come soon into play
From there, on the Node.js side we’ll use the Airtable API send data. I’ll also briefly introduce you to Airtable.
Start your work from this project: https://glitch.com/edit/#!/node-course-project-newsletter-a
Rename the public/client.js
file, and name it public/form.js
. Also change the filename in the views/index.html
file.
In there, we start by creating an event listener for the DOMContentLoaded
event, so we only execute the JavaScript when the DOM is ready:
document.addEventListener("DOMContentLoaded", () => {
})
Inside this callback, we attach a submit event listener to the form:
document.addEventListener("DOMContentLoaded", () => {
document.querySelector('form').addEventListener('submit', (event) => {
})
})
Here we stop the event propagation, and we prevent the default behavior:
event.stopPropagation()
event.preventDefault()
Now when we submit the form, nothing happens. The browser is instructed to not automatically do what it does normally without JavaScript.
Next up, we get the form data:
const name = document.querySelectorAll('form input[name="name"]')[0].value
const email = document.querySelectorAll('form input[name="email"]')[0].value
Here’s the full code:
document.addEventListener("DOMContentLoaded", () => {
document.querySelector('form').addEventListener('submit', (event) => {
event.stopPropagation()
event.preventDefault()
const name = document.querySelectorAll('form input[name="name"]')[0].value
const email = document.querySelectorAll('form input[name="email"]')[0].value
})
})
Now, we intercepted the form data. We need to send it to our server to process.
First we create a POST endpoint using Express in our server.js
file.
We add
app.use(express.json())
after we create the Express app, as we’ll send data in the application/json
format, and we need to tell Express to use the JSON middleware to process it.
Then we add the /form
endpoint:
app.post('/form', (req, res) => {
console.log(req.body.name)
console.log(req.body.email)
res.end()
})
In the frontend, I include Axios by adding
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
to the index.html
file. Axios is a powerful network library which makes network requests easy. Read more about it at https://flaviocopes.com/axios/.
Inside our submit event callback, we add
axios.post('/form', {
name,
email
})
Tip: ignore the red bubble saying ‘axios’ is not defined in Glitch. It will work.
That’s it! Now our backend code receives the data correctly.
We are ready to interact with the Airtable API in the next lesson
Project now: https://glitch.com/edit/#!/node-course-project-newsletter-b
Save the form data to Airtable
Airtable is quite an amazing product!
It’s half database, half spreadsheet, but most of all, easy to use, intuitive, great UI and UX, and a great API.
Their API docs are the best you can imagine, they even inject your keys and table names in the examples, to make it even easier to start.
I wrote an extensive Airtable tutorial at https://flaviocopes.com/airtable/. Go check it out before continuing, as I won’t repeat the basics there, but just go in-depth into using it in the project.
In our Node server we receive the data from the form.
Let’s first setup Airtable. Once you sign up, create an empty base. Call it “Email Newsletter Signups” or something else you like.
By default you have 3 columns:
- Name
- Notes
- Attachments
Remove the last 2, and add
- “Email” of type Email
- “Date” of type Date, including time
Rename “Table 1” to “Signups” to give it a better name.
Airtable is nice because they have a great API documentation. You can see it at Help -> API documentation . They also provide Node.js examples, which is very nice for this course.
Plus, for us Node.js fans, they have an official Node client library: Airtable.js
Switch back to Glitch now, and open the package.json
file. Click Add Package and search “airtable”:
Click it to add it to the project.
Now back to server.js.
Let’s clean up the code a bit. currently this is the file content:
// server.js
// where your node app starts
// init project
var express = require('express');
var app = express();
app.use(express.json())
// we've started you off with Express,
// but feel free to use whatever libs or frameworks you'd like through `package.json`.
// http://expressjs.com/en/starter/static-files.html
app.use(express.static('public'));
// http://expressjs.com/en/starter/basic-routing.html
app.get('/', function(request, response) {
response.sendFile(__dirname + '/views/index.html');
});
app.post('/form', (req, res) => {
console.log(req.body.name)
console.log(req.body.email)
res.end()
})
// listen for requests :)
var listener = app.listen(process.env.PORT, function() {
console.log('Your app is listening on port ' + listener.address().port);
});
Let’s first remove the comments, and transform the code to be a little bit more modern, with arrow functions, const, and I remove the semicolons as I like more that style (you don’t need to, if you like them):
const express = require('express')
const app = express()
app.use(express.json())
app.use(express.static('public'))
app.get('/', (req, res) => {
res.sendFile(__dirname + '/views/index.html')
})
app.post('/form', (req, res) => {
console.log(req.body.name)
console.log(req.body.email)
res.end()
})
const listener = app.listen(process.env.PORT, () => {
console.log('Your app is listening on port ' + listener.address().port)
})
Let’s add the Airtable.js library, and we initialize the base and table objects:
const Airtable = require('airtable')
Airtable.configure({
apiKey: process.env.AIRTABLE_API_KEY
})
const base = require('airtable').base(process.env.AIRTABLE_BASE_NAME)
const table = base(process.env.AIRTABLE_TABLE_NAME)
Go in the .env file and fill the values of those environment variables:
AIRTABLE_API_KEY
AIRTABLE_BASE_NAME
AIRTABLE_TABLE_NAME
We use environment variables because this is the perfect scenario for this kind of thing. By just changing those values, you can adapt the same codebase to work with any other Airtable base, without changing anything else. Also, Glitch makes them private, so even if you decide to make your Glitch app public, those values are hidden:
AIRTABLE_API_KEY=XXXX
AIRTABLE_BASE_NAME=YYYY
AIRTABLE_TABLE_NAME=ZZZZ
Now in the /form
POST handler we can create a new record into our Airtable base:
app.post('/form', (req, res) => {
const name = req.body.name
const email = req.body.email
const date = (new Date()).toISOString()
table.create({
"Name": name,
"Email": email,
"Date": date
}, (err, record) => {
if (err) {
console.error(err)
return
}
console.log(record.getId())
})
res.end()
})
Try it, it should work! You should see the record in Airtable:
We should do something here, though: we need to validate the input our POST endpoint receives. We do so using the express-validator
library. See more on that on validating input in Express using express-validator.
Just like we did for Airtable.js, we add express-validator
to the package.json
file
then we add
const { check } = require('express-validator/check')
and in the post()
call, we add an array as the first argument with a list of checks we need to perform. We’ll use the isEmail()
, isAlpha()
, isLength()
checks:
app.post('/form', [
check('name').isAlpha().isLength({ min: 3, max: 100 }),
check('email').isEmail()
], (req, res) => {
const name = req.body.name
const email = req.body.email
const date = (new Date()).toISOString()
})
While we’re here, we can add frontend validation as well. We’ll use the same library that express-validator
uses under the hoods: validator.js.
Add
<script src="https://cdnjs.cloudflare.com/ajax/libs/validator/10.7.0/validator.min.js"></script>
to views/index.html
.
In public/form.js
we can reuse the check we added for name
. Email is already taken care by the type="email"
input field.
if (!validator.isAlphanumeric(name) || !validator.isLength(name, { min: 3, max: 100 })) {
alert('Name must be alphanumeric and between 3 and 100 chars')
return
}
Full code:
document.addEventListener("DOMContentLoaded", () => {
document.querySelector('form').addEventListener('submit', (event) => {
event.stopPropagation()
event.preventDefault()
const name = document.querySelectorAll('form input[name="name"]')[0].value
const email = document.querySelectorAll('form input[name="email"]')[0].value
if (!validator.isAlphanumeric(name) || !validator.isLength(name, { min: 3, max: 100 })) {
alert('Name must be alphanumeric and between 3 and 100 chars')
return
}
axios.post('/form', {
name,
email
})
})
})
This should be good as a start!
Let’s add a final piece to the puzzle: when the form is successfully sent, I want to remove all the content from the DOM main
element, and add a “Success!” paragraph. We can do so by adding a .then()
to the axios.post()
call, because that call returns a promise.
In there, we first remove all items from the main
tag:
for (const item of document.querySelectorAll('main *')) { item.remove() }
Then I create a p
tag with the string:
const p = document.createElement('p')
const text = document.createTextNode('Success!')
p.appendChild(text)
and append it to the main
tag:
document.querySelector('main').appendChild(p)
axios.post('/form', {
name,
email
}).then(() => {
for (const item of document.querySelectorAll('main *')) { item.remove() }
const p = document.createElement('p')
const text = document.createTextNode('Success!')
p.appendChild(text)
document.querySelector('main').appendChild(p)
})
All the code we did in this lesson is available on Glitch :・゚✧
Implement the authentication for the admin panel
Now that we have the email capture phase in place, we can start diving into the administration panel.
This panel will be served on the /admin
route. We need authentication first. How would we do that?
We want a single user to ever log into the application in the admin side.
This means that most of the traditional authentication systems are overkill: we don’t really need to setup a username and password authentication, OAuth or any of that.
What I will do in this case is, I will create a single JWT token and send it to the client that first loads the /admin
URL.
I assume you will deploy this application in a server, and right after you open /admin in your browser, and that browser will receive the JWT token.
No other user can then access the application admin side, except you. Well, you can’t even access it using another browser. Let’s go with this limitation for learning purposes, and we’ll later setup a better system in another app.
It’s simple but we’ll introduce several concepts.
Let’s start.
Introducing JWT
JWT stands for JSON Web Token and it’s one of the most popular authentication mechanisms on the Internet.
Things basically work in this way: when a token is generated, it’s sent to the browser, which stores it in a cookie. The cookie is then passed back to the server on every request, making sure the client is authenticated when performing the request.
This token can also host bits of data, like a user identifier for example. It’s all unencrypted data, so you can’t store sensitive information in there.
In Node.js we have a great library to interact with JWT: jsonwebtoken
. Add it to your package.json
file.
We create an auth.js
file with this content:
const jwt = require('jsonwebtoken')
exports.createJWT = options => {
return jwt.sign({}, process.env.JWT_SECRET, {
expiresIn: options.maxAge || 3600
})
}
exports.verifyJWT = token => {
return new Promise((resolve, reject) => {
jwt.verify(token, process.env.JWT_SECRET, (err, decodedToken) => {
if (err || !decodedToken) {
return reject(err)
}
resolve(decodedToken)
})
})
}
This module exports 2 functions: createJWT
and verifyJWT
. Those use the jsonwebtoken
module to create and verify the authenticity of a token. I won’t go in the details of the implementation, but it’s important to see that they abstract the tiny details, and verifyJWT
returns a promise.
They use the JWT_SECRET
environment variable as the secret value. Put it into your .env
file, and assign it a random string value of your choice.
Now, let’s go and use this library in our main server.js
file.
Here, first we add those 2 requires on top:
const fs = require('fs')
const { createJWT, verifyJWT } = require('./auth')
const cookieParser = require('cookie-parser')
fs
will be used to write a file to disk when the application authentication is initialized. We’ll create an empty file in .data/initialized
.
cookie-parser
will be used to check the cookies. The JWT token once generated is sent to the client as a token
cookie. The client will send it back on every request, and we must parse the cookies to access and verify it. Express by default does not provide any cookies facility, so we use the cookie-parser
middleware for that.
We then add the cookieParser()
function as an Express middleware:
app.use(cookieParser())
We listen on the /admin
route, and we first check if the .data/initialized
file exists:
const initializedFile = './.data/initialized'
app.get('/admin', (req, res) => {
if (fs.existsSync(initializedFile)) {
//admin auth already initialized
} else {
//admin auth not initialized
}
})
If it does not exist, which is the initial state of the app, we create a JWT token and we set it as the cookie in the response.
The token is created with duration of 1 year.
The browser will automatically save this cookie, and send it back in every response.
We create the .data/initialized
file, so subsequent requests will find it and determine that the token has already been sent and authentication is done.
We then set the httpOnly
and secure
flags in the cookie. This is very important because this minimizes the security issues, first by requiring the cookie to only be sent via HTTPS, and by making it only accessible by the server, and not by JavaScript in the client.
In the end, we send the ./views/admin.html
file contents.
Create this file, and just write “ADMIN!” into it, so that we know we were allowed admin access.
const initializedFile = './.data/initialized'
app.get('/admin', (req, res) => {
if (fs.existsSync(initializedFile)) {
//admin auth already initialized
} else {
const token = createJWT({
maxAge: 60 * 24 * 365 //1 year
})
fs.closeSync(fs.openSync('./.data/initialized', 'w'))
res.cookie('token', token, { httpOnly: true, secure: true })
res.sendFile(__dirname + '/views/admin.html')
}
})
If the file exists, which happens on every subsequent request, we verify the token, and either send an error message, or the ./views/admin.html
page.
const initializedFile = './.data/initialized'
app.get('/admin', (req, res) => {
if (fs.existsSync(initializedFile)) {
verifyJWT(req.cookies.token).then(decodedToken => {
res.sendFile(__dirname + '/views/admin.html')
}).catch(err => {
res.status(400).json({message: "Invalid auth token provided."})
})
} else {
const token = createJWT({
maxAge: 60 * 24 * 365 //1 year
})
fs.closeSync(fs.openSync('./.data/initialized', 'w'))
res.cookie('token', token, { httpOnly: true, secure: true })
res.sendFile(__dirname + '/views/admin.html')
}
})
Resetting the token
Now when we set the token, we have no way to revoke it. Let’s make an /admin/reset
endpoint. When called, the token is verified from the cookies, the ./data/initialized
file is removed, so that the next time we access the page, we can get a new one.
app.get('/admin/reset', (req, res) => {
try {
if (fs.existsSync(initializedFile)) {
verifyJWT(req.cookies.token).then(decodedToken => {
fs.unlink(initializedFile, err => {
if (err) {
console.error('Error removing the file')
res.status(500).end()
return
}
res.send('Session ended')
})
}).catch(err => {
res.status(400).json({message: "Invalid auth token provided."})
})
} else {
res.status(500).json({message: "No session started."})
}
} catch(err) {
console.error(err)
}
})
A few considerations
This authentication strategy is not ideal in many situations.
For example, we cannot use a different browser to authenticate. We must first call /admin/reset
from the authenticated browser, or remove the .data/initialized
file from the file system.
But for our simple use case (single-user app), we can live with it. Since we have this very limited use, we can also limit the app access by IP, use a server-level protection or we can deploy it privately.
Also, there’s another thing. The authentication system is prone to CSRF attacks if we’ll later use forms and AJAX calls.
To mitigate it, we must deploy an additional strategy, and it will be the subject of one of the assignments I propose in the end.
We must first add another cookie we call XSRF-TOKEN
, with a unique value, and we’ll send it in an additional HTTP header called X-XSRF-TOKEN
in the HTTP requests.
Keep this in mind before deploying it in the wild.
The project at this point
The full project is available at https://glitch.com/edit/#!/node-course-project-newsletter-e
Listing items from Airtable
Now that we have a private area, which grants access only to you, we can go and implement our newsletter admin panel.
Let’s list all the emails that were entered in Airtable.
We’re going to make a server-side generated page that lists them.
First we must fetch the list. We’re going to use the airtable
library, like we did to add a new email.
If you remember, we used this code:
table.create({
"Name": name,
"Email": email,
"Date": date
}, (err, record) => {
if (err) {
console.error(err)
return
}
console.log(record.getId())
})
Go back to your Airtable page, and click the “Help > API documentation”. Scroll all the way down to “List records”:
The right part of the page gives us a great example, which works great if we’re only interested in the first 100 page records:
base('Signups').select({
view: 'Grid view'
}).firstPage(function(err, records) {
if (err) { console.error(err); return; }
records.forEach(function(record) {
console.log('Retrieved', record.get('Name'));
});
});
We need to handle pagination, though. We’re going to list all the emails in a single page, even if they are more than 100.
How do we do that? The documentation tells us we need to call the fetchNextPage()
function.
We must build a recursive function, to call it until the records end, to fill the records
array. We create a getAirtableRecords()
function, which returns a promise:
let records = []
const getAirtableRecords = () => {
return new Promise((resolve, reject) => {
//return cached results if called multiple times
if (records.length > 0) {
resolve(records)
}
// called for every page of records
const processPage = (partialRecords, fetchNextPage) => {
records = [...records, ...partialRecords]
fetchNextPage()
}
// called when all the records have been retrieved
const processRecords = err => {
if (err) {
console.error(err)
return
}
resolve(records)
}
table
.select({
view: process.env.VIEW_NAME
})
.eachPage(processPage, processRecords)
})
}
The bulk of this is the airtable
library API call:
table.select({
view: process.env.VIEW_NAME
}).eachPage(processPage, processRecords)
Inside it, we’ll fill the records
array one page at a time. The processPage()
inner function processes each page, and at the end of it all processRecords()
is called.
We’ll use the ES7 async/await syntax to load all the records synchronously:
const getEmails = async () => {
records = []
const emails = await getAirtableRecords()
}
Once we get the emails, we can get the data we want using map()
to iterate and create a new, clean array:
const getEmails = async () => {
records = []
const emails = await getAirtableRecords()
return emails.map(record => {
return {
'email': record.get('Email'),
'name': record.get('Name'),
'date': record.get('Date')
}
})
}
We can get the array of data from inside the /admin
route callback:
getEmails().then(emails => {
console.log(emails)
})
We want this data to be available in the admin.html template. How do we do that? How do we pass data to an Express template, and how do we use this data?
First, we change the file extension from .html
to .pug
to use the Pug templating language. Pug is the new version of Jade, and I ask you to look at https://flaviocopes.com/pug to get an introduction.
With Pug we can rewrite
<!DOCTYPE html>
<html lang="en">
<head>
<title>Join my list</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<main>
<h1>Join my list</h1>
</main>
</body>
</html>
to:
html
head
title(
meta(charset='utf-8')
meta(http-equiv='X-UA-Compatible', content='IE=edge')
meta(name='viewport', content='width=device-width, initial-scale=1')
link(rel='stylesheet', href='/style.css')
body
main
h1 List of emails
Before we can use Pug we must install it. Add the pug
library to the package.json
file, and in server.js
add
app.set('view engine', 'pug')
after initializing Express.
Now, in server.js
find where you call verifyJWT()
. In the callback, we have res.sendFile(__dirname + '/views/admin.html')
.
The sendFile()
method just serves the file from disk without giving us the option to pass in data. We need to use another method here.
Switch to using a combination of Response.render()
and Response.end()
, and wrap it in the getEmails()
successful promise resolution:
getEmails().then(emails => {
res.render(__dirname + '/views/admin.pug', { emails: emails })
res.end()
})
We pass the emails list as a parameter. Inside the admin.pug
file we can get this parameter just by typing emails
. Let’s use a loop to iterate on them, and create a nice table:
html
head
meta(charset='utf-8')
meta(http-equiv='X-UA-Compatible', content='IE=edge')
meta(name='viewport', content='width=device-width, initial-scale=1')
link(rel='stylesheet', href='/style.css')
body
main
h1 List of emails
table
for entry in emails
tr
td #{entry.name}
td #{entry.email}
td #{entry.date}
If you have some data in Airtable, the list will show up like this:
Pretty cool!
Let’s give it some CSS styling, to improve the presentation:
table {
padding: 30px;
width: 100%;
-webkit-border-horizontal-spacing: 0px;
-webkit-border-vertical-spacing: 0px;
}
tr:nth-child(odd) {
background-color: lightblue;
}
td {
padding: 8px;
}
Things should look much better now:
There’s one thing missing now: the format of the date. It’s ugly, there’s too much information we don’t really care about.
Let’s make it prettier using the time-ago
NPM package.
Add it to package.json
and add
const ta = require('time-ago')
at the top of server.js
.
Then change the getEmails()
function to use the ta.ago()
method:
const getEmails = async () => {
records = []
const emails = await getAirtableRecords()
return emails.map(record => {
return {
'email': record.get('Email'),
'name': record.get('Name'),
'date': ta.ago(record.get('Date'))
}
})
}
This is the result now:
You can see this project live on Glitch :・゚✧
Create the composer panel
Let’s add a new thing to the admin panel: a composer view.
We just add it on top of the emails list, without adding an additional view.
I am going to use the simplest editor I can find. I found pell, which markets itself as the smallest and simplest editor for the web.
Looks like what I’m looking for! We don’t want to overcomplicate the frontend, after all it’s a Node course.
We just need to add this to our admin.pug
file:
html
head
link(rel='stylesheet', href='https://unpkg.com/pell/dist/pell.min.css')
body
main
h1 Compose email
div#editor.pell
//- the existing content goes here
//- (at the end)
script(src='https://unpkg.com/pell')
script(src='/editor.js')
Now create a public/editor.js
file with this content:
document.addEventListener("DOMContentLoaded", () => {
pell.init({
element: document.getElementById('editor'),
onChange: html => document.emailHtml = html,
defaultParagraphSeparator: 'p',
styleWithCSS: true,
actions: [
'bold',
'underline',
{
name: 'italic',
result: () => pell.exec('italic')
},
{
name: 'link',
result: () => {
const url = window.prompt('Enter the link URL')
if (url) pell.exec('createLink', url)
}
}
]
})
})
You don’t really need to understand what’s going on here, since it’s frontend code, but basically we initialize the editor with some buttons. It’s a very minimal setup, which gives us this nice simple editor view:
Let’s create a button that will send the email to the list. Add:
button#send Send email
hr
after the editor in views/admin.pug
.
Then add this CSS to public/style.css
:
/* make the text in the editor align left */
.pell-content {
text-align: left;
}
button#send {
margin-top: 15px;
}
You should see this:
Now when the button is clicked nothing happens, because it’s not put in any form.
We can listen for clicks in the editor.js
file. In there, we’ll put an Axios call to our backend, passing the email text.
Notice I didn’t put a subject field. You will add it yourself in one of the project challenges later.
When we change the content, we store the content of the editor in the document.emailHtml
global variable. Not ideal, but works (again, it’s not a frontend development course).
We can reference it in our click event listener:
document.querySelector('button#send').addEventListener('click', (event) => {
console.log(document.emailHtml)
})
Now, we use Axios. Add
script(src='https://unpkg.com/axios/dist/axios.min.js')
at the end of views/admin.pug
.
We add the Axios call to the click event listener:
axios.post('/send', {
content: document.emailHtml
}).then(() => {
document.querySelector('button#send').innerText = 'Sent!'
document.querySelector('button#send').disabled = true
}).catch(err => {
console.error(err)
alert('An error occurred! Check the console log')
})
Let’s switch to server.js
and create a /send
POST endpoint.
app.post('/send', (req, res) => {
const content = req.body.content
console.log(content)
res.end()
})
Now when you press the send button, the server should log the email content.
See the app at the current point on Glitch :・゚✧
Sending emails
Now that we have the endpoint ready to be called, we can go in the details of sending the email newsletter.
app.post('/send', (req, res) => {
const content = req.body.content
//we'll send the emails here
res.end()
})
When you have up to a reasonable amount of emails in the database, let’s say 1000, just using your email server’s SMTP works fine.
But above some number of emails, you need to implement a queue system to avoid overloading your memory and the SMTP server.
This is how you send 1 single email using nodemailer
, a popular package that simplifies handling sending emails, using any SMTP account:
const nodemailer = require('nodemailer')
//...
const to = ''
const subject = ''
const body = ''
//SMTP_USERNAME, SMTP_PASSWORD, SMTP_HOST, SMTP_PORT are environment variables to authenticate with your SMTP server
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
secure: true,
auth: {
user: process.env.SMTP_USERNAME,
pass: process.env.SMTP_PASSWORD
}
})
const mailOptions = {
from: process.env.FROM_EMAIL,
to: to,
subject: subject,
html: body
}
transporter.sendMail(mailOptions, (err, info) => {
if (err) {
console.log(err)
} else {
// email sent
console.log(info)
}
})
It’s highly recommended that you use a dedicated, paid SMTP server to send transactional emails, like Mailgun, Sendgrid or Mandrill. Use your own email SMTP server only for a small amount of emails. For our simple example, your email SMTP will work fine, but keep it in mind.
If you don’t want to send real emails here, sign up for Mailtrap.io, which is a fake SMTP server well suited for this use case.
First, add nodemailer
to your package.json
file.
Then add this to the /send
endpoint:
app.post('/send', (req, res) => {
const body = req.body.content
const subject = 'Newsletter from me!'
getEmails().then(emails => {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
secure: true,
auth: {
user: process.env.SMTP_USERNAME,
pass: process.env.SMTP_PASSWORD
}
})
const sleep = (milliseconds) => {
return new Promise(resolve => setTimeout(resolve, milliseconds))
}
const sendEmail = (mailOptions, i, emails, res) => {
if (i === emails.length) {
res.end()
return
}
mailOptions.to = emails[i].email
transporter.sendMail(mailOptions, (err, info) => {
if (err) {
console.log(err)
} else {
// email sent
sleep(500).then(() => {
sendEmail(mailOptions, ++i, emails, res)
})
}
})
}
const mailOptions = {
from: process.env.FROM_EMAIL,
subject: subject,
html: body
}
sendEmail(mailOptions, 0, emails, res)
})
})
This code does the following: first we fetch the body of the email from the request body (warning: we still need to filter and validate it!), then we get the emails from Airtable using the getEmails()
function we added in the previous lessons.
Next, we initialize nodemailer and we use a recursive structure, with a little sleep between each call, to send emails without overloading the SMTP server.
In the challenges proposed in the next lesson you’ll improve this code yourself.
See the app at the current point on Glitch :・゚✧
Wrapping up, and challenges
You can clone the project at https://github.com/flaviocopes/node-course-project-newsletter and implement the following coding challenges. Once you are done, you can post your result in Discord and ask for feedback!
- Design a nicer page to be sent when another user tries to reach
/admin
. You can use another browser to test it, or your browser in incognito/private mode. - Add the option to edit the emails subject
- Right now when a user presses “Send”, the email is sent as-is. Filter and validate the email body in the
/send
POST endpoint. Require at least 10 characters and remove any script tag. - When sending the email, there is no direct feedback. The user can click the button twice or more. Immediately mark it as disabled, and show a spinner.
- Add an indication of how many emails were sent, and how many remain. Consider the use of web sockets for this.
- Disallow duplicates in email submission, send a custom error message from Node to the frontend, and display an “email already registered” message to the user.
- Handle double opt-in for forms. Send an email when a new email is entered, with a confirmation link.
- Acknowledge and fix the problem with CSRF and JWT. Reference this blog post Where to Store JWTs - Cookies vs HTML5 Web Storage