Build Robust & Production Quality Applications - Lesson 6: Email Service Providers

With Gmail, there are clear sending limitations.

Sending out emails is something that you should offload onto a third-party provider as it is a critical business process. You want to ensure emails are recieved right away and not in the recipients email folder.

Mailgun & Postmark are the recommended email service providers. When considering vendors, you want to focus on devliverability.

Check out the is Quora article on comparing well-known email service providers. In this course, we will use Mailgun.

Good blogpost for getting set up with Gmail.

Build Robust & Production Quality Applications - Lesson 6: ActiveSupport::Concerns

In our Todos example, we created a method

before_create :generate_token

for and then defined the generate_ token method under private, within the Todos model.

But what if you want to include this in several models? DRY does not only mean "Dont Repeat Yourself", but it also means to have one authoritative source for method calls

That's where ActiveSupport::Concerns can help by basically extracting the common code into a module

lib/tokenable.rb
module Tokenable
extend ActiveSupport::Concern

  included do
    before_create :generate_token
  end

  def generate_token
    self.token = SecureRandom.urlsafe_base64
  end
end

Then in the Todo model:

class Todo < ActiveRecord::Base
  include Tokenable

With Rails 3 or later, the lib directory is no longer automatically loaded You instead have to:

require_relative '../..lib/tokenable'

class Todo < ActiveRecord::Base
  include Tokenable

end

Now if you dont want to require_relative everytime: You can do one of the two:

  1. move the Tokenable file to your models folder
  2. go to /enviornments/application.rb config.autoload_paths << "#{Rails.root}/lib"

then remove require_relative from the model.

Build Robust & Production Quality Applications - Lesson 6: Inviting Users- Part 3

Feature Spec: Inviting Users

require 'spec_helper'

feature "User invited friend" do
  scenario "User succesfully intvites friend and invitation is accepted" do
    jam = Fabricate(:user)
    sign_in(jam)

    invite_a_friend
    friend_accepts_invitation
    friend_signs_in

    friend_should_follow(jam)
    inviter_should_follow_friend(jam)

    clear_email
  end

  def invite_a_friend
    visit new_invitation_path
    fill_in "Friend's Name", with: 'Amber Howard'
    fill_in "Friend's Email Address", with: 'amberhoward1@gmail.com'
    fill_in "Message", with: "Hey girl, you gotta check this out!"
    click_button "Send Invitation"
    sign_out
  end

  def friend_accepts_invitation
    open_email "amberhoward1@gmail.com"
    current_email.click_link "Accept this invitation"

    fill_in "Password", with: "password"
    fill_in "Full Name", with: "Amber Howard"
    click_button "Sign Up"
  end

  def friend_signs_in
    fill_in "Email Address", with: 'amberhoward1@gmail.com'
    fill_in "Password", with: 'password'
    click_button "Sign in"
  end

  def friend_should_follow(inviter)
    click_link "People"
    expect(page).to have_content inviter.full_name
    sign_out
  end

  def inviter_should_follow_friend(inviter)
    sign_in(inviter)
    click_link "People"
    expect(page).to have_content "Amber Howard"
  end

end

Build Robust & Production Quality Applications - Lesson 6: Inviting Users- Part 2

Inviting Users - 2nd part of Workflow:

  1. User gets email and selects the "Accept this invitation" link. URL will contain special token
  2. Registation form has email prefilled
  3. Upon sign up, user automatically follows recommender and vice versa

Step 1. User recieves email:

#views/app_mailers/send_invitation_email.html.haml
!!! 5
%html(lang="en-US")
  %body
  %p You are inivited by #{@invitation.inviter.full_name} to join Myflix!
  %p= @invitation.message
  %p= link_to "Accept this invitation", register_with_token_url(@invitation.token)

Create routes

resources :users, only: [:create, :show]
  get 'register/:token', to: "users#new_with_invitation_token", as: 'register_with_token'
#users_controller_spec.rb
describe "GET new_with_invitation_token" do
  it "render the :new view template" do
    invitation = Fabricate(:invitation)
    get :new_with_invitation_token, token: invitation.token
    expect(response).to render_template :new
  end

  it "sets @user with recipient's email" do
    invitation = Fabricate(:invitation)
    get :new_with_invitation_token, token: invitation.token
    expect(assigns(:user).email).to eq(invitation.recipient_email)
  end

  it "sets @invitation_token" do
    invitation = Fabricate(:invitation)
    get :new_with_invitation_token, token: invitation.token
    expect(assigns(:invitation_token)).to eq(invitation.token)
  end

  it "redirects to expired token page for invalid tokens" do
    get :new_with_invitation_token, token: 'asdffdhh'
    expect(response).to redirect_to expired_token_path
  end

We need to fabricate an invitation:

#fabricators/invitation_fabricators.rb
Fabricator(:invitation) do
  recipient_name { Faker::Name.name }
  recipient_email { Faker::Internet.email }
  message { Faker::Lorem.paragraphs(2).join(" ") }
end

Add Tokens Column to Invitations:

class AddTokenToInvitations < ActiveRecord::Migration
  def change
    add_column :invitations, :token, :string
  end
end
#users_controller.rb
  def new_with_invitation_token
    invitation = Invitation.where(token: params[:token]).first
    if invitation
      @user = User.new(email: invitation.recipient_email)
      @invitation_token = invitation.token
      render :new
    else
      redirect_to expired_token_path
    end
  end

  private
  def user_params
    params.require(:user).permit!
  end

  def handle_invitations
    if params[:invitation_token].present?
      invitation = Invitation.where(token: params[:invitation_token]).first
      @user.follow(invitation.inviter)
      invitation.inviter.follow(@user)
      invitation.update_column(:token, nil)
    end
  end
end

User registers upon clicking link

#users/new.html.haml
%section.register.container
  .row
    .col-sm-10.col-sm-offset-1
      = bootstrap_form_for @user, html: { class: "form-horizontal"} do |f|
        %header
          %h1 Register
        %fieldset
          .col-sm-6
            = f.email_field :email, label: "Email Address"
            = f.password_field :password
            = f.text_field :full_name, label: "Full Name"
            = hidden_field_tag :invitation_token, @invitation_token
        %fieldset.actions.control-group.col-sm-offset-0
          .controls
            %input(type="submit" value="Sign Up" class="btn btn-default")

Gotchas

Another issue with the current form is when the invitee signs up, we lose the lose inviter's info and they are not not following the inviter. To fix this, we need a hidden field on the invitee registration form. This hidden field is a field tag, rather than model backed becasue we don't want this token to be stored under the user's token.

Step 3: Inviter/Invitee automatically follow each other after being invited:

#users_controller_spec.rb
   describe "POST create" do
    context "with valid input" do
      before { post :create, user: Fabricate.attributes_for(:user) }
      it "creates the user" do
        expect(User.count).to eq(1)
      end
      it "redirects to th sign on page" do
        expect(response).to redirect_to sign_in_path
      end

      it "makes the user follow the inviter" do
        alice = Fabricate(:user)
        invitation = Fabricate(:invitation, inviter: alice, recipient_email: 'jam@jamblack.com')
        post :create, user: {email: 'jam@jamblack.com', password: 'password', full_name: 'Jam Black'}, invitation_token: invitation.token
        jam = User.where(email: 'jam@jamblack.com').first
        expect(jam.follows?(alice)).to be_true
      end

      it "makes the inviter follow the user" do
        alice = Fabricate(:user)
        invitation = Fabricate(:invitation, inviter: alice, recipient_email: 'jam@jamblack.com')
        post :create, user: {email: 'jam@jamblack.com', password: 'password', full_name: 'Jam Black'}, invitation_token: invitation.token
        jam = User.where(email: 'jam@jamblack.com').first
        expect(alice.follows?(jam)).to be_true
      end
      it "expires the invitation upon acceptance" do
        alice = Fabricate(:user)
        invitation = Fabricate(:invitation, inviter: alice, recipient_email: 'jam@jamblack.com')
        post :create, user: {email: 'jam@jamblack.com', password: 'password', full_name: 'Jam Black'}, invitation_token: invitation.token
        jam = User.where(email: 'jam@jamblack.com').first
        expect(Invitation.first.token).to be_nil
      end
    end
describe "#follows?" do
    it "returns true if the user has a following relationship with another user" do
      alice = Fabricate(:user)
      bob = Fabricate(:user)
      Fabricate(:relationship, leader: bob, follower: alice)
      expect(alice.follows?(bob)).to be_true
    end

    it "returns false if the user does not have a following relationship with another user" do
      alice = Fabricate(:user)
      bob = Fabricate(:user)
      Fabricate(:relationship, leader: alice, follower: bob)
      expect(alice.follows?(bob)).to be_false
    end
  end

  describe "#follow" do
    it "follows another user" do
      alice = Fabricate(:user)
      bob = Fabricate(:user)
      alice.follow(bob)
      expect(alice.follows?(bob)).to be_true
    end

    it "does not follow one self" do
      alice = Fabricate(:user)
      alice.follow(alice)
      expect(alice.follows?(alice)).to be_false
    end
  end
end
#users.rb
  def follows?(another_user)
    following_relationships.map(&:leader).include?(another_user)
  end

  def can_follow?(another_user)
    !(self.follows?(another_user) || self == another_user)
  end

  def follow(another_user)
    following_relationships.create(leader: another_user) if can_follow?(another_user)
  end
end

Build Robust & Production Quality Applications - Lesson 6: Inviting Users

Let's write add functionality to allow users to invite their friends:

Workflow:

  1. User can input friends name and email, click invite friend link
  2. An email is sent to friend
  3. Friend open email, clicks link and then is directed to Registration Form
  4. Email address is pre-filled
  5. Upon sign up, user automatically follows recommender and vice versa

Steps:

1. Copy ui controller into views/invitations/new.html.haml
%section.invite.container
  .row
    .col-sm-10.col-sm-offset-1
      %form.invite
        %header
          %h1 Invite a friend to join MyFlix!
        %fieldset
          .form-group
            %label Friend's Name
            .row
              .col-sm-4
                %input.form-control(type="text")
          .form-group
            %label Friend's Email Address
            .row
              .col-sm-4
                %input.form-control(type="email")
          .form-group
            %label Invitation Message
            .row
              .col-sm-6
                %textarea.form-control(rows=6) Please join this really cool site!
        %fieldset.form-group.action
          %input(type="submit" value="Send Invitation" class="btn btn-default")

2. Edit template to create need attributes: recipient name, email, message

section.invite.container
  .row
    .col-sm-10.col-sm-offset-1
      %form.invite
      = bootstrap_form_for @invitation, class: "invite" do |f|
        %header
          %h1 Invite a friend to join MyFlix!
        %fieldset
          = f.text_field :recipient_name, label: "Friend's Name"
          = f.text_field :recipient_email, label: "Friend's Email Address"
          = f.text_area :message, class: "span4", rows: 6, placeholder:  "Please join this really cool site!", label: "Message"
        %fieldset.form-group.action
          %input(type="submit" value="Send Invitation" class="btn btn-default")

3. Create new and create routes for invitations

resources :invitations, only: [:new, :create]

4. Create InvitationsController

5. Begin with InvitationsController spec

6. Create context: for valid inputs and invalid inputs

GET new

#invitation_controller_spec.rb
require 'spec_helper'

describe InvitationsController do
  describe "GET new" do
    it "sets @invitation to a new invitation" do
      set_current_user
      get :new
      expect(assigns(:invitation)).to be_new_record
      expect(assigns(:invitation)).to be_instance_of Invitation
    end
    it_behaves_like "requires sign in" do
      let(:action) { get :new }
    end
  end
class InvitationsController < ApplicationController
  before_filter :require_user
  def new
    @invitation = Invitation.new
  end

Generate Migration

class CreateInvitations < ActiveRecord::Migration
  def change
    create_table :invitations do |t|
      t.integer :inviter_id
      t.string :recipient_name, :recipient_email
      t.text :message
      t.timestamps
    end
  end
end

Create Invitation Model/Model Spec

#invitation.rb
class Invitation < ActiveRecord::Base
before_create :generate_token
  belongs_to :inviter, class_name: "User"
  validates_presence_of :recipient_name, :recipient_email, :message

  def generate_token
    self.token = SecureRandom.urlsafe_base64
    end
  end
#invitation_model_spec.rb
require 'spec_helper'

describe Invitation do
  it { should validate_presence_of(:recipient_name) }
  it { should validate_presence_of(:recipient_email) }
  it { should validate_presence_of(:message) }

POST create

Context: with valid Inputs

#invitations_controller_spec.rb
  describe "POST create" do
    it_behaves_like "requires sign in" do
      let(:action) { post :create }
    end
    context "with valid input" do
      it "redirects to ithe invitation new page" do
        set_current_user
        post :create, invitation: { recipient_name: "Jam Black", recipient_email: "jam@jamblack.com", message: "Join Myflix!" }
        expect(response).to redirect_to new_invitation_path
      end

      it "creates the invitation" do
        set_current_user
        post :create, invitation: { recipient_name: "Jam Black", recipient_email: "jam@jamblack.com", message: "Join Myflix!" }
        expect(Invitation.count).to eq(1)
      end
      it "sends an email to the recipient" do
        set_current_user
        post :create, invitation: { recipient_name: "Jam Black", recipient_email: "jam@jamblack.com", message: "Join Myflix!" }
        expect(ActionMailer::Base.deliveries.last.to).to eq(['jam@jamblack.com'])
      end

      it "sets the flash success message" do
        set_current_user
        post :create, invitation: { recipient_name: "Jam Black", recipient_email: "jam@jamblack.com", message: "Join Myflix!" }
        expect(flash[:success]).to be_present
      end
    end

Context: With invalid inputs

#invitations_controller_spec.rb
    context "with invalid input" do
      after { ActionMailer::Base.deliveries.clear }
      it "renders the :new template" do
        set_current_user
        post :create, invitation: { recipient_email: "jam@jamblack.com", message: "Join Myflix!" }
        expect(response).to render_template :new
      end

      it "does not create an invitation" do
        set_current_user
        post :create, invitation: { recipient_email: "jam@jamblack.com", message: "Join Myflix!" }
        expect(Invitation.count).to eq(0)
      end
      it "does not send out an email" do
        set_current_user
        post :create, invitation: { recipient_email: "jam@jamblack.com", message: "Join Myflix!" }
        expect(ActionMailer::Base.deliveries).to be_empty
      end

      it "sets the flash error message" do
        set_current_user
        post :create, invitation: { recipient_email: "jam@jamblack.com", message: "Join Myflix!" }
        expect(flash[:error]). to be_present
      end

      it "sets #@invitation" do
        set_current_user
        post :create, invitation: { recipient_email: "jam@jamblack.com", message: "Join Myflix!" }
        expect(assigns(:invitation)).to be_present
      end
    end
  end
end
#invitations_controller.rb
  def create
    @invitation = Invitation.create(invitation_params.merge!(inviter_id: current_user.id))

    if @invitation.save
      AppMailer.send_invitation_email(@invitation).deliver
      flash[:success] = "You've successfully invited #{@invitation.recipient_name}."
      redirect_to new_invitation_path
    else
      flash[:error] = "Please fill in all inputs."
      render :new
    end
  end

  private
  def invitation_params
    params.require(:invitation).permit(:recipient_name, :recipient_email, :message, :inviter_id)
  end
end
class AppMailer < ActionMailer::Base
  def send_invitation_email(invitation)
    @invitation = invitation
    mail to: invitation.recipient_email, from: "info@myflix.com", subject: "Invitation to join Myflix"
  end
end

Remember to:

clear the ActionMailer deliveries in the valid input context so they don't interfer with the invalid inputs. - we want that number to be 0