E-mail follow up

Users can now be matched. The meetings are unique and made between people that are nearby, which is awesome! There is no end to possible improvements on a matching system; so instead, lets now collect some data about how their meeting went!

To do so, we'll send an email to each of the attendees, which will consist of a few simple options to promote engagement. Some of them are listed as follows:

  • It was awesome
  • It was awful
  • Meh…
  • My pair didn't show up!

Those values are added to src/models/meeting.js as key-value pairs, which we can store for ratings and use them to communicate back to users.

  methods.outcomes = function() {
    return {
      awesome : "It was awesome",
      awful   : "It was awful",
      meh     : "Meh",
      noshow  : "My pair didn't show up!"
    }
  }

We could store these responses in the respective meeting object, associating it with the user who responded.

For this purpose, we'll rely primarily on the package Nodemailer (https://github.com/andris9/Nodemailer). It is broadly used and offers support for a number of integrations, including transport providers and templates so we can make our e-mails dynamic.

Coming to the setup decision, as you probably realized Node.js & Express are free of conventions about how to set up your code because these apps may do very different things and there is no one-size-fits-all. Let's make mailing a concern of its own, as much as persistence and routes are separated concerns integrated into src/app.js.

The src/mailer/index.js will be our entry point and its main responsibility is to instantiate the nodemailer variable and provide public methods other files can refer to.

'''
var nodemailer = require('nodemailer')

module.exports = function (mailConfig){
  var methods = {};
  var transporter;

  // Setup transport
  if(process.env.NODE_ENV == 'test'){
    var stubTransport = require('nodemailer-stub-transport'),
    transporter = nodemailer.createTransport(stubTransport());
  } else if( mailConfig.service === 'Mailgun'){
    transporter = nodemailer.createTransport({
        service: 'Mailgun',
        auth: {
            user: mailConfig.user,
            pass: mailConfig.password
        }
    });
  } else {
    throw new Error("email service missing");
  }

  // define a simple function to deliver mails
  methods.send = function(recipients, subject, body, cb) {
    // small trick to ensure dev & tests emails go to myself
    if(process.env.NODE_ENV !== 'production') {
      recipients = ["[email protected]"];
    }
    transporter.sendMail({
      to: recipients,
      from: mailConfig.from,
      subject: subject,
      generateTextFromHTML: true,
      html: body
    }, function(err, info) {
      // console.info("nodemailer::send",err,info)
      if(typeof cb === 'function'){
        cb(err,info);
      }
    })
  }

  return methods;
}

When it comes to the test environment, we definitely don't want to be sending real e-mails, that's why we register the stub transport. For other environments, we decided to go with Mailgun but we could also go with any service that integrates via SMTP (remember to use Gmail since there is a risk of failing to send e-mails, as they have a bunch of heuristics to prevent spam).

When it comes to testing, this section is one of the harder ones to test, we will implement something very basic in test/send_mail.js

var dbCleanup = require('./utils/db'),
var app = require('../src/app'),
var mailer = app.get('mailer'),

describe('Meeting Setup', function() {

  it('just send one.', function(done) {
    this.timeout(10*1000);
    mailer.send(
      "[email protected]",
      "Test "+(new Date()).toLocaleString(),
      "Body "+Math.random()+"<br>"+Math.random()
    , done);
  })
})

Add to config.js, and have the correspondent environment variables defined because it's not a good idea to keep our secrets in the code.

  var ENV = process.env;
  configs.email = {
    service: "Mailgun",
    from: ENV.MAIL_FROM,
    user: ENV.MAIL_USER,
    password: ENV.MAIL_PASSWORD
  };

When I disable the test environment, I can actually see the email in my inbox. Win! To make the service look better, let's experiment with some templates, which is what email-templates (https://github.com/niftylettuce/node-email-templates) is all about.

It makes it easy to implement dynamic e-mails including packing the CSS inline; these are required to be inline by many e-mail clients.

On src/mailer/followUp.js

'''javascript
module.exports = function(sendMail, models) {
  //..

  function sendForUser (user1, user2, id, date, cb) {
    emailTemplates(templatesDir, function(err,template) {
      if(err) return cb(err);

      template('followup', {
        meetingId: id.toString(),
        user1    : user1,
        user2    : user2,
        date     : date,
        outcomes : Meeting.outcomes()
      }, function(err,html) {
        if(err) return cb(err);
        sendMail(
          user1.email,
          "How was your meeting with "+user2.name+"?",
          html,
          cb
        )
      });
    });
  }

  // call done() when both emails are sent
  return function followUp(meeting, done) {
    async.parallel([
      function(cb) {
        sendForUser(meeting.user1, meeting.user2, meeting._id, meeting.at, cb);
      },
      function(cb) {
        sendForUser(meeting.user2, meeting.user1, meeting._id, meeting.at, cb);
      },
    ], done)
  }
}

Essentially, we send two identical emails so we get feedback from both users. There is a bit of complexity there that we will manage by using async.parallel() method. It allows us to start two asynchronous operations and callbacks (done) when both are completed. See https://github.com/caolan/async#parallel.

The actual print of the email is created by two files, src/mailer/templates/followup/followUp.html.swig and style.css, which are combined and set via our transport solution, respectively:

'''html
<h4 class="title">
  Hey {{user1.name}},
</h4>
<div class="text">
  We hope you just had an awesome meeting with {{user2.name}}!
  You guys were supposed to meetup at {{date|date('jS of F H:i')}}, how did it go?
</div>
<ul>
  {% for id, text in outcomes %}
  <li><a href="http://127.0.0.1:8000/followup/{{meetingId}}/{{user2._id.toString()}}/{{id}}">{{text}}</a></li>
  {% endfor %}
</ul>
<div class="text">
  Hope to see you back soon!
</div>
'''

'''css
body{
  background: #EEE;
  padding: 20px;
}
.text{
  margin-top: 30px;
}
ul{
  list-style-type: circle;
}
ul li{1
  line-height: 150%;
}
a{
  text-decoration: none;
}

We can choose from many template solutions. swig (http://paularmstrong.github.io/swig/docs/) comes with convenient helpers, makes it easy to work with lists, and has the familiar HTML visual. A bit of insight is given as follows:

  • {{string}} is the general interpolating method
  • | is for helpers (aka filters); you can use built-ins or define your own
  • for k,v in obj is a tag and works looping over key-value pairs
E-mail follow up

When it came to the logic for the follow-up links, we made it really easy for the user to provide feedback; usually, the less friction, the better for outstanding UX. All they have to do is click on the link and their review is instantly recorded! In terms of Express.js, this means we have to set up a route that links all the piece of data together; in this case, in src/routes/index.js:

  app.get("/followup/:meetingId/:reviewedUserId/:feedback", meeting.followUp);

To have an endpoint that actually changes the data defined as a GET is an exception to HTTP & REST conventions, but the reason is that email clients will send the request as a GET; not a lot we can do about it.

The method is defined at src/routes/meeting.js as follows:

  methods.followUp = function(req,res,next) {
    var meetingId = req.param("meetingId");
    var reviewedUserId = req.param("reviewedUserId");
    var feedback = req.param("feedback");
    // validate feedback
    if(!(feedback in Model.Meeting.outcomes())) return res.status(400).send("Feedback not recognized");
    Model.Meeting.didMeetingHappened(meetingId, function(err, itDid) {
      if(err){
        if(err.message == "no meeting found by this id"){
          return res.status(404).send(err.message);
        } else {
          return next(err);
        }
      }
      if(!itDid){
        return res.status(412).send("The meeting didn't happen yet, come back later!");
      }
      Model.Meeting.rate(meetingId, reviewedUserId, feedback, function(err, userName, text) {
        if(err) return next(err);
        res.send("You just rated your meeting with "+userName+" as "+text+". Thanks!");
      });
    });

  }

This method does quite a bit of checking and that's because there is a considerable amount of input that needs validation along with providing the appropriate response. First, we check whether the feedback provided is valid, since we are only taking quantitative data. didMeetingHappened returns two important pieces of info about the meeting; the ID may be completely wrong, or it might not have happened yet. Both scenarios should deliver different results. Finally, if everything looks good, we attempt to rate the meeting, which should work just fine and return some data to respond with and finish the request with an implied 200 status.

The implementation of the preceding methods are available at src/models/meeting.js

'''
  // cb(err, itDid)
  methods.didMeetingHappened = function(meetingId, cb) {
    if(!db.ObjectId.isValid(meetingId)) return cb(new Error("bad ObjectId"));
    Meeting.findOne({
      user1: {$exists: true},
      user2: {$exists: true},
      _id: new db.ObjectId(meetingId)
    }, function(err, meeting) {
      if(err) return cb(err);
      if(!meeting) return cb(new Error('no meeting found by this id'));
      if(meeting.at > new Date()) return cb(null,false);
      cb(null,true);
    })
  }

  // cb(err, userName, text)
  methods.rate = function(meetingId, reviewedUserId, feedback, cb) {
    Meeting.findOne({
      _id: new db.ObjectId(meetingId),
    }, function(err,meeting) {
      if(err) return cb(err)
        var update = {};
        // check the ids present at the meeting object, if user 1 is being reviewed then the review belongs to user 2
        var targetUser = (meeting.user1._id.toString() == reviewedUserId) ? '1' : '2';
        update["user"+targetUser+"review"] = feedback;
        Meeting.findAndModify({
          new: true,
          query: {
            _id: new db.ObjectId(meetingId),
          },
          update: {
            $set: update
          }
        }, function(err, meeting) {
          if(err) return cb(err);
          var userName = (meeting["user"+targetUser].name);
          var text = methods.outcomes()[feedback];
          cb(null, userName, text);
        })
    })
  }
'''

The implementation method should be pretty readable. The didMeetingHappened() method looks for a maximum of one document with _id, where user1 and user2 are filled. When this document is found, we look at the at field and compare with the current time to check whether it already happened.

The rate is a bit longer but just as simple. We find the meeting object and figure out which user is being rated. Such feedback belonging to the opposite user is stored in an atomic operation, setting either field user1reviewed or user2reviewed with the key for the feedback.

We have a thorough test suite implemented for this case, where we mind both success & failure cases. It can be used to check the emails by simply calling the test with NODE_ENV=development mocha test/meeting_followup.js, which then overrides the test environment with development and delivers emails to our provider, so we can see how it looks and fine-tune it.

Our test for this whole scenario is a bit long but we need to test several things!

  • Clean up DB
  • Setting up the meeting
  • Register user 1
  • Register user 2 at the same position
  • STest that non-existent meetings can't be reviewed
  • Status 412 on meeting reviews that still didn't happen
  • Travel time two days ahead
  • Send an email
  • Taking up a review that makes sense
  • User 1 should be able to review the meeting
  • User 2 should be able to review the meeting as well

Seems like we can now send emails and receive reviews, which is great, but how do we send the emails in a time-sensitive manner? A couple of minutes after the meeting has started, the emails should be sent to both parties.

(Source:git checkout 7f5303ef10bfa3d3bfb33469dea957f63e0ab1dc)

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.223.237.29