Anatomy of an email notification setup

Anatomy of an email notification setup

How to setup and email notification system for your application with basic considerations for templates with dynamic data and running the application in development, staging and production environments.

Email notifications are an integral part of any application. Whether it is about informing users of some required actions, or be it an acknowledgement to some of the actions that they performed in the application, emails are used to keep user engaged and informed about what they could expect next. And when I say emails, I do not mean those mass marketing emails, but the specific user targeted transactional emails that every application needs, to keep users posted about their user journey in the application. For an e-commerce platform, this could mean sending emails like order confirmation, product delivery status, etc.

On the surface level this might look like a trivial task, however there are many factors to consider to be able to build something generic that could be used for all your transactional email needs from the different parts of your application. Some of the very basic things that we would need to consider are:

  • Email templates – We would want to create generic dynamic templates with placeholders, that could be used to send out similar emails to different users, just by passing the right data.
  • Reply-to address – We would want to have different categories of reply-to addresses for our emails. It's useful for logical grouping so that we can generate automated responses and take appropriate actions when the user replies to those emails. Eg:
    We may have some emails that users should never reply to and hence a [email protected]. While on the other hand say for all payment related emails we may have a [email protected] as the reply-to email address, so that we can have an automated ticketing system when someone replies to those email addresses. And it doesn't end there, we can even have a totally dynamic address generated for specific user actions so that a reply can be redirected to specific actions in the application, e.g. replying to email for a message notification, could actually send a message response to the sender.
  • Testing email delivery – Now to test out the application features and user flow on a staging or development environment, we would like the application to behave normally and send out email notifications like it usually does in the normal production environment. However the big catch is, we wouldn't want the emails to get to the inbox of our actual users. We would like all the emails from a non-production environment to get redirected to an internal shared test email group where we could see those emails. And while working locally with the application or during an automated test run, triggering those emails should just validate the signature and get redirected to some kind of a sandbox in order to not spam the shared email group.

The list is not exhaustive and depending upon your application needs, there might be some additional considerations that you would need to make. Nonetheless, the above points are crucial and in my opinion the bare minimum to any email notification service. Though there are a numerous ways to implement these points in your application, I'd show a setup with the use of a third party email service provider, SendGrid and a bit of code, to give you an idea which you can use to build upon. The same concept could be applied if we are using any other similar third party email service provider. However, in case we are using our own mail servers to deliver emails, it's a whole different implementation story that demands an article on its own.

Email templates and Reply-to address

For the dynamic email templates, SendGrid provides the option for creating transactional email templates using their web tools (or even APIs if you prefer that) and have placeholders within those templates, exactly what we needed. Once you have created a template, you get a unique template-id which you can later use to send the email using their APIs/SDKs. However those ids are like how ids should be, non-human-readable, and so it's a pain to manually use them in our code, each time we need to invoke those email templates. Furthermore, as we mentioned earlier, we would like to have specific reply-to addresses attached to specific category of emails, as, manually setting those with each API call, doesn't make much of sense. And so we would create named templates with defaults, that we can use from all parts of our code.

// we would see the implementation of `sendEmail` function
// later, for now we just assume it exists
const sendEmail = require('../sendEmail')
// we would see the implementation of the utils later
// for now we assume that the utils exist
const {fromCompanyEmail, applyDefaultsToEmailData} = require('../utils')

const DEFAULTS = {
  // we use a specific reply-to address
  // based on the type of email template we are defining
  // this will ensure we always have the same reply-to
  // email address for the same email template
  from: fromCompanyEmail('[email protected]'),
  // this is the template id that we get when we
  // create the transactional email in `SendGrid`
  templateId: 'd-48xxxx46a4xxxx0da23a0xxxx8xxx632',
}

module.exports = body => sendEmail(applyDefaultsToEmailData(body, DEFAULTS))
orderConfirmation.js inside the templates folder

We can create multiple such templates as per our needs and add them to the templates folder. Then create the index.js file exporting all our templates.

module.exports = {
  orderConfirmation: require('./orderConfirmation'),
  shipmentStatus: require('./shipmentStatus'),
  userActivation: require('./userActivation'),
  // ... and more
}
index.js inside the templates folder

Testing email delivery

Now this is the most important bit, where the magic happens. We would implement our wrapper sendEmail function that we mentioned earlier. The function would internally use the third party email service APIs/SDKs to deliver the email. It would check the deployment environment during runtime to determine whether to redirect the email or to let the information flow through as is. Let me show the code and highlight the points with the help of code comments.

const _ = require('lodash')
// this email client is the SendGrid's SDK
const emailClient = require('./client')

// whether it's an actual server deployment or a local development
const isProd = process.env.NODE_ENV === 'production'
// make sure to set the `DEPLOYMENT_ENV` to `staging` or `production`
// depending upon where you run this service
const DEPLOYMENT_ENV = process.env.DEPLOYMENT_ENV

const DEFAULT_STAGING_EMAIL_ADDR = '[email protected]'
const DEFAULT_STAGING_BCC_EMAIL_ADDR = '[email protected]'

// following behavior is enabled for the emails
// - on production deploys, the emails are sent as normal
// - on staging deploys, the emails are redirected to [email protected]
// - on local development, the emails are just using SendGrid's sandbox

const canSendEmails =
  isProd && (DEPLOYMENT_ENV === 'production' || DEPLOYMENT_ENV === 'staging')
const redirectEmails = DEPLOYMENT_ENV === 'staging'

// set some details and overrides
// depending upon where the service is running
let ADDITIONAL_DETAILS = {}
let OVERRIDDEN_BCC_EMAIL = null
if (canSendEmails) {
  if (redirectEmails) {
    ADDITIONAL_DETAILS = {to: DEFAULT_STAGING_EMAIL_ADDR}
    OVERRIDDEN_BCC_EMAIL = DEFAULT_STAGING_BCC_EMAIL_ADDR
  }
} else {
  ADDITIONAL_DETAILS = {mail_settings: {sandbox_mode: {enable: true}}}
}

const formatEmailData = body => {
  const message = _.assign({}, body, ADDITIONAL_DETAILS)

  if (message.bcc && OVERRIDDEN_BCC_EMAIL) {
    message.bcc = OVERRIDDEN_BCC_EMAIL
  }

  return message
}

module.exports = async function sendEmail(body) {
  const data = Array.isArray(body)
    ? _.map(body, formatEmailData)
    : formatEmailData(body)

  return emailClient.send(data)
}
sendEmail.js to be used by the previously created template functions
const sgMail = require('@sendgrid/mail')
const {SENDGRID_API_KEY} = process.env

sgMail.setApiKey(SENDGRID_API_KEY)

module.exports = sgMail
client.js to be used as the emailClient to invoke SendGrid's APIs
const _ = require('lodash')

const fromCompanyEmail = (
  email = '[email protected]',
  name = 'My e-commerce.com'
) => ({email, name})

const applyDefaultsToEmailData = (body, defaults) => {
  const defaultsFn = data => _.assign({}, data, defaults)
  return Array.isArray(body) ? _.map(body, defaultsFn) : defaultsFn(body)
}

module.exports = {
  fromCompanyEmail,
  applyDefaultsToEmailData,
}
utils.js that we used in the template functions
Email service setup

So finally, we have the setup that we need, with all the basic ingredients to get us going.

// use the template methods from the appropriate
// parts of your application code

mailService.templates.orderConfirmation({
  to: '[email protected]',
  dynamic_template_data: {name: 'Max', ...}
})

mailService.templates.shipmentStatus({
  to: '[email protected]',
  dynamic_template_data: {name: 'Max', ...}
})
Sample usage of the email service

I hope by now you got a concrete idea about what it takes to build an email service that could be used throughout the different parts of your application, in a scalable and error-free manner. Building and maintaining our own mail servers to send out emails, is a whole different topic which I would leave out for a future article.


If you happen to run into issues or spot any mistakes with this article, please feel free to comment and I'd try my best to help you out / correct the mistakes.

Happy Coding!