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

  1. We need to connect our app to Mailgun.
  2. A background task (cronjob) needs to be made to run our app's sender.
  3. 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

  1. Create a Heroku account
  2. Create an app
  3. Provision logging, a Postgres database and a Heroku Scheduler
  4. If your code is on Github, sync the repo with the app, otherwise use Heroku's CLI
  5. Create a CNAME record under your domain and get launching!

Well, it's been fun

I guess...like...we're kinda done?

👉 https://remind.ist 👈

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,326