Continuing Frameworkless.js
Part 6: Emails, cronjobs & deploying!
Recap of Last Time
- We built a Node.js app utilising the built-in http.createServer
- Starts an HTTP server and serves static files out of the ./public directory
- We implemented dynamic routing with a handy easy to define syntax
- Our routes use Handlebars template files to display content that's not static
- We also wrote a super simple form data parser that will allow us to capture user data
- We connected up a database, using SQL queries mixed into "actions" to contain them
- We made a pipeline making it easier for us to keep our frontends nice
What are we building again?
Ever wanted to remind yourself of something, out of the blue? Well, we're building a quick tool to do that!
What's it all for?
- To start, this course was to prove that
you don't need a framework. - Sometimes, not thinking properly about your architectural choices can lead you down a wrong path. Always consider the true cost of a framework!
- Don't avoid frameworks just because either – they serve a very useful purpose.
- An MVP should be simple. Simplify your MVP build by focusing away from learning/working in frameworks!
What we're going to do
- We need to connect our app to Mailgun.
- A background task (cronjob) needs to be made to run our app's sender.
- We deploy to Heroku!
Let's get started with the mailer
We're going to use Mailgun, but any transactional provider will do.
$ npm i --save mailgun-js
- You will need to go to mailgun.com, create an account.
- You will also probably need to set up the MX/DKIM records on a domain name you own
We're already using Handlebars...
We're already using Handlebars.js in our app's views, so may as well use it in emails too!
(reduce, re-use, recycle)
$ mkdir emails
$ touch index.js
$ touch reminder.hbs
Let's create our email
{{!-- emails/reminder.hbs --}}
🎯 REMINDER from {{timeago reminder.created_at}}
---
<p>Hi there, {{email}}!</p>
<p>{{message}}</p>
<p><small>You are receiving this email because you asked <a href="https://remind.ist?ref=email">{{app_name}}</a> to remind you! Now that this message is sent, it has self-destructed in our database.</small></p>
Emails: Loading the files in
// emails/index.js
const { readFileSync } = require('fs')
const Handlebars = require('handlebars')
const glob = require('glob')
const hbsHelpers = require('../lib/handlebar-helpers')
Object.keys(hbsHelpers).map(helper => {
Handlebars.registerHelper(helper, hbsHelpers[helper])
})
const mails = {}
for (const mail of glob.sync('./emails/**.hbs')) {
const pathTokens = mail.split('/')
const key = pathTokens[pathTokens.length - 1].slice(0, -4)
const [ subject, body ] = readFileSync(mail, { encoding: 'utf8' }).split('---')
mails[key] = { subject, body }
}
Emails: the compile function
// emails/index.js
const compile = (emailName, context) => {
const mail = mails[emailName]
if (!mail) throw new Error(`Email ${emailName} not found.`)
const body = Handlebars.compile(mail.body)
const subjectTemplate = Handlebars.compile(mail.subject)
const from = MAILGUN_SEND_EMAIL_FROM || `Remind.ist <bot@${MAILGUN_DOMAIN}>`
const compileContext = {
app_name: APP_NAME,
...context
}
const subject = subjectTemplate(compileContext)
const message = {
from,
to: context.email,
subject,
html: body({ ...compileContext, subject }),
'o:tag': [ emailName ],
'o:tracking': 'True'
}
return message
}
Emails: sending via mailgun
// emails/index.js
// Near the top...
const MailgunClient = require('mailgun-js')
const { MAILGUN_API_KEY, MAILGUN_DOMAIN, APP_NAME, MAILGUN_SEND_EMAIL_FROM } = process.env
if (!MAILGUN_API_KEY || !MAILGUN_DOMAIN) throw new Error(
'MAILGUN_API_KEY & MAILGUN_DOMAIN are required env variables.'
)
const client = MailgunClient({
apiKey: MAILGUN_API_KEY,
domain: MAILGUN_DOMAIN,
username: 'api'
})
// Anywhere, really...
const send = async email => {
try {
const send = await client.messages().send(email)
console.log(`=> Email queued: ${send.id}`)
} catch (error) {
console.error(`=> Error sending email: ${error.message}`)
if (error.stack) console.log(error.stack)
}
}
// End
module.exports = { compile, send }
Now, we need a way to send them...
$ mkdir bin/reminders
$ touch bin/reminders/send
$ chmod +x bin/reminders/send
A daily task designed to be run in the server environment will send the mails for us by using our compile and send functions!
The magic task...
#!/usr/bin/env node
if (!process.env.DATABASE_URL) require('dotenv').config()
const db = require('../../initialisers/postgres')
const { compile, send } = require('../../emails')
const run = () => new Promise(async resolve => {
const now = new Date()
now.setUTCHours(0)
now.setUTCMinutes(0)
now.setUTCSeconds(0)
now.setUTCMilliseconds(0)
console.log(`=> Running mailer job for ${now.toISOString()}`)
const { rows: reminders } = await db.query(
'SELECT * FROM reminders WHERE send_at = $1',
[ now ]
)
console.log(` ${reminders.length} outstanding emails found.`)
if (reminders.length < 1) {
console.log(` Zoinks – no emails need sending, let's get out of here!`)
return resolve()
}
for (const reminder of reminders) {
const message = await compile('reminder', reminder)
await send(message)
await db.query('DELETE FROM reminders WHERE reminder_id = $1', [ reminder.reminder_id ])
}
return resolve(0)
})
run()
.then(process.exit)
.catch(error => {
console.error(error)
process.exit(2)
})
Time to execute the task
$ bin/reminders/send
A "cronjob" is the simplest way of doing this:
0 9 * * * /path_to_app/bin/reminders/send
* Or, we could use Heroku's scheduler
What is heroku?
- Heroku is a hosting platform. Their hosting has an emphasis on the 12 factor app, so it's perfect for us!
- They have a free tier so you can tinker all you want before launch.
- They have a number of free addons built in that make your life easier.
- In the long run though, if you want to learn to do deployments on your own or if cost is a factor, you will want to migrate away from Heroku, especially at scale.
Heroku deployment checklist
- Create a Heroku account
- Create an app
- Provision logging, a Postgres database and a Heroku Scheduler
- If your code is on Github, sync the repo with the app, otherwise use Heroku's CLI
- Create a CNAME record under your domain and get launching!
Well, it's been fun
I guess...like...we're kinda done?
Congrats! You built it!
I guess we've done it!
It doesn't have to end – I am going to gather feedback, see what people want, and then maybe continue. Let me know what you think please!
Telegram channel: https://t.me/frameworkless
Twitter: @mtimofiiv
See today's code: https://github.com/frameworkless-js/remind.ist/tree/stage/6
See today's lesson running live: https://remind.ist
frameworkless.js -> Part 6
By Mike Timofiiv
frameworkless.js -> Part 6
Check out https://frameworkless.js.org/course/6 for the whole presentation
- 1,300