DEV Community

Andy Leverenz
Andy Leverenz

Posted on • Originally published at web-crunch.com on

Email Subscription Workflow - Ruby on Rails

Everywhere I've looked for some guidance on how to best tailor email subscriptions in a Ruby on Rails app has turned up fairly empty. After some trial and error I a found method that fits my needs so I wanted to share it.

What do you mean by email subscriptions exactly?

When building web applications there is probably a need for you to send emails to your users at some point. There is a vast array of emails you can send to an audience. The emails I'm focusing on today pertain more toward notification style emails.

Examples:

  • John Doe created a message
  • Jane Doe responded to a message

Imagine if you wrote some blog posts for example and someone comments on it. You probably want to get notified about that. In some cases, you might not. Offering this option is good practice.

Core concepts

Many apps you use have unsubscribe links in their promotional or transactional emails. Clicking that link directs you to a page on their website that either opts you out of that given communication feed or gives you an option to choose what you receive.

Behind the scenes, there is one or many boolean fields that are toggled either on or off depending on your preferences. In most apps, these can be set within your user profile settings or from the email directly. Let's build a primitive version of this!

Part 1

Part 2

Part 3

Part 4

Part 5

Part 6

Kicking things off

I'll be using my kickoff_tailwind Ruby on Rails application template to kick off a new app. You can use a fresh rails app all the same. My template simply saves me some time and configuration.

We'll be leveraging Devise in this tutorial as a place to add/remove/update the email preferences of a given user. I recommend following along and using it alongside me for max compatibility.

Let's build a very primitive project management app. The data layer looks something like the following:

Create a new app

First clone the kickoff_tailwind repo

git clone https://github.com/justalever/kickoff_tailwind
Enter fullscreen mode Exit fullscreen mode

Outside of that folder, you can create a new app using the template:

$ rails new email_subscriptions -m kickoff_tailwind/template.rb
Enter fullscreen mode Exit fullscreen mode

The Data Layer

When thinking about the database layer of the app I like to build an outline before creating any new resources. This helps me establish relationships between things before I get too far down one path only to become stuck.

  • User
    • has_many :projects
    • name - Comes with my kickoff_tailwind template
    • username - Comes with my kickoff_tailwind template
    • all other fields we get with Devise - Comes with my kickoff_tailwind template
  • Project - model
    • belongs_to :user
    • has_many :tasks
    • title
    • description
  • ProjectUser - A model for adding/associating multiple users to a project
    • belongs_to :user
    • belongs_to :project
  • Task
    • belongs_to :project
    • body

We ultimately want other users on a given project to get notified via email about new projects that are created and new tasks that are created.

Ruby on Rails makes creating all the logic/UI we need quite easy with scaffolds and generators. It's not the prettiest it could be but it should get the job done for what we need in this tutorial.

$ rails g scaffold Project title:string description:text user:belongs_to
$ rails g scaffold Task body:text project:belongs_to complete:boolean
$ rails g model ProjectUser user:belongs_to project:belongs_to
$ rails db:migrate
Enter fullscreen mode Exit fullscreen mode

One final thing we need to do is address our models. Our Project model will be able to have more than one Task so we need to add some logic to accommodate.

# app/models/project.rb

class Project < ApplicationRecord
  belongs_to :user
  has_many :tasks, dependent: :destroy # add this line
end
Enter fullscreen mode Exit fullscreen mode

The dependent: :destroy addition here means that if a given project is "destroyed(deleted)" the associated tasks with that project will be deleted as well.

Our User model needs some love as well

# app/models/user.rb

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  has_many :projects # add this line
end
Enter fullscreen mode Exit fullscreen mode

Much of the logic here is from Devise which was installed if you used my kickoff_tailwind template.

And with that our core relationships are set up.

My template currently is configured to use a home#index method as our root path. Let's update the routes file to make that project#index instead. I'll also update some route nesting so that tasks live within the namespace of a given project.

# config/routes.rb

require 'sidekiq/web'

Rails.application.routes.draw do
  resources :projects do
    resources :tasks
  end

  authenticate :user, lambda { |u| u.admin? } do
    mount Sidekiq::Web => '/sidekiq'
  end

  devise_for :users
  root to: 'projects#index'
end
Enter fullscreen mode Exit fullscreen mode

Here's the updated code

Authentication

While this tutorial isn't about authentication I can't but help to add a bit of it around creating new Projects, Tasks, etc.. so if you're new to the framework you can get a grasp on how to keep things more secure.

We can append a before action within the projects_controller.rb file and tasks_controller.rb file

# app/controllers/projects_controller.rb

class ProjectsController < ApplicationController
  before_action :authenticate_user!
  ...
 end


# app/controllers/tasks_controller.rb

class TasksController < ApplicationController
  before_action :authenticate_user!
  ...
 end
Enter fullscreen mode Exit fullscreen mode

The method authenticate_user! we get for free from Devise. Now, hitting any page route relative to Projects or Tasks requires a login. There's a gotcha with this approach related to routing now since we just changed our routes file.

When logged in I would prefer the root path to be something more meaningful. I updated the routes.rb file to accommodate.

# config/routes.rb
require 'sidekiq/web'

Rails.application.routes.draw do
  resources :projects do
    resources :tasks
  end

  authenticate :user, lambda { |u| u.admin? } do
    mount Sidekiq::Web => '/sidekiq'
  end

  devise_for :users

  # add the folloiwng code
  devise_scope :user do
    authenticated :user do
      root "projects#index"
    end

    unauthenticated do
      root "home#index", as: :unauthenticated_root
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Devise helps us once again with a new routing devise_scope method for both authenticated and non-authenticated users. We can define different root paths depending on those states. Pretty handy!

Note: You'll need to create a new account now to continue to any project or task.

Quick UI updates

Thanks to Tailwind CSS our forms are completely reset and rather hard to use. I'll add some basic styling to get them in at least usable shape. I'll also add navigation links to projects to make things easier on ourselves.

Here's the state of my layout file

<!-- app/views/layouts/application.html.erb-->
<!DOCTYPE html>
<html>
  <head>
    <title>Email Subscriptions</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <meta name="viewport" content="width=device-width, initial-scale=1">

    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>

  </head>

 <body class="bg-white">

  <% flash.each do |type, message| %>
    <% if type == "alert" %>
      <div class="bg-red-500">
        <div class="container px-2 py-4 mx-auto font-sans font-medium text-center text-white"><%= message %></div>
      </div>
    <% end %>
    <% if type == "notice" %>
      <div class="bg-green-500">
        <div class="container px-2 py-4 mx-auto font-sans font-medium text-center text-white"><%= message %></div>
      </div>
    <% end %>
  <% end %>

    <header class="mb-4">
      <nav class="flex flex-wrap items-center justify-between px-3 py-3 text-gray-700 bg-gray-100 border-b border-gray-400 lg:px-10">
        <div class="flex items-center mr-6 flex-no-shrink">
          <%= link_to "Email Subscriptions", root_path, class:"link text-xl tracking-tight font-semibold" %>
        </div>
        <div class="block lg:hidden">
          <button class="flex items-center px-3 py-2 border border-gray-500 rounded text-grey hover:text-gray-600 hover:border-gray-600">
            <svg class="w-3 h-3 fill-current" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><title>Menu</title><path d="M0 3h20v2H0V3zm0 6h20v2H0V9zm0 6h20v2H0v-2z"/></svg>
          </button>
        </div>
        <div class="items-center block w-full text-center lg:flex-1 lg:flex lg:text-left">
          <div class="lg:flex-grow">
            <%= link_to "Projects", projects_path, class: "block mt-4 lg:inline-block lg:mt-0 lg:mr-4 mb-2 lg:mb-0 link" %>
          </div>
          <div class="items-center block w-full mt-2 text-center lg:flex lg:flex-row lg:flex-1 lg:mt-0 lg:text-left lg:justify-end">
            <% if user_signed_in? %>
             <%= link_to "Log out", destroy_user_session_path, method: :delete, class:"btn btn-default mb-2 lg:mr-2 lg:mb-0 block" %>
            <% else %>
              <%= link_to "Login", new_user_session_path, class:"btn btn-default mb-2 lg:mr-2 lg:mb-0 block" %>
            <%= link_to "Sign Up", new_user_registration_path, class:"btn btn-default block" %>
            <% end %>
          </div>
        </div>
      </nav>
    </header>

    <main class="px-4 lg:px-10">
      <%= content_for?(:content) ? yield(:content) : yield %>
    </main>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

And the Projects form

<!-- app/views/projects/_form.html.erb -->
<%= form_with(model: project, local: true) do |form| %>
  <% if project.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(project.errors.count, "error") %> prohibited this project from being saved:</h2>

      <ul>
        <% project.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="mb-6">
    <%= form.label :title, class: "label" %>
    <%= form.text_field :title, class: "input" %>
  </div>

  <div class="mb-6">
    <%= form.label :description, class: "label" %>
    <%= form.text_area :description, class: "input" %>
  </div>

  <%= form.submit class: "btn btn-default" %>

<% end %>
Enter fullscreen mode Exit fullscreen mode

And the Tasks form:

<!-- app/views/tasks/_form.html.erb -->

<%= form_with(model: task, local: true) do |form| %>
  <% if task.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(task.errors.count, "error") %> prohibited this task from being saved:</h2>

      <ul>
        <% task.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="mb-6">
    <%= form.label :body, class: "label" %>
    <%= form.text_area :body, class: "input" %>
  </div>

  <%= form.submit class: "btn btn-default" %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

The class names and button styles you see are also part of my kickoff_tailwind template. They are components I created with grouped Tailwind CSS classes. You can find that CSS inside app/javascript/stylesheets/components.

Quick Controller Updates

Because our Project model has a belongs_to :user declaration the database expects a user_id parameter upon saving a new project. We can make this happen by first removing the field user_id from the view (as shown in the previous section) and append it to the Project object within the controller during creation. That looks something like this:

class ProjectsController < ApplicationController
  before_action :authenticate_user!
  before_action :set_project, only: [:show, :edit, :update, :destroy]

  ...

  def create
    @project = Project.new(project_params)
    @project.user = current_user # add this line

    respond_to do |format|
      if @project.save
        format.html { redirect_to @project, notice: 'Project was successfully created.' }
        format.json { render :show, status: :created, location: @project }
      else
        format.html { render :new }
        format.json { render json: @project.errors, status: :unprocessable_entity }
      end
    end
  end

  ...

 end
Enter fullscreen mode Exit fullscreen mode

A single line here makes all the difference. If you tried to create a project prior to this you might have gotten an error message like:

1 error prohibited this project from being saved:
User must exist
Enter fullscreen mode Exit fullscreen mode

Adding that line should make things right again. Try to create your first project now.

project-saved

Success!

Create some test data

Let's add some dummy data. Create a couple of projects first.

new-projects-index-ugly

Our Project Index looks pretty crappy. Let's fix that a touch.

<!-- app/views/projects/index.html.erb -->

<h1 class="text-3xl font-bold">Projects</h1>

<div class="grid grid-cols-12 gap-6 mb-10">
<% @projects.each do |project| %>
  <div class="col-span-12 p-6 border rounded shadow lg:col-span-3">
    <%= link_to project, class: "block" do %>
      <h3 class="mb-4 text-lg font-bold"><%= project.title %></h3>
      <p class="leading-snug text-gray-600"><%= truncate(project.description, length: 200) %></p>
    <% end %>
  </div>
<% end %>
</div>

<div class="my-4">
  <%= link_to 'New Project', new_project_path, class: "btn btn-default" %>
</div>
Enter fullscreen mode Exit fullscreen mode

That gets us here:

projects-grid

And where we'll be working more is the project show view.

<!-- app/views/projects/show.html.erb -->
<h1 class="text-3xl font-bold">Projects</h1>

<div class="grid grid-cols-12 gap-6 mb-10">
<% @projects.each do |project| %>
  <div class="col-span-12 p-6 border rounded shadow lg:col-span-3">
    <%= link_to project, class: "block" do %>
      <h3 class="mb-4 text-lg font-bold"><%= project.title %></h3>
      <p class="leading-snug text-gray-600"><%= truncate(project.description, length: 200) %></p>
    <% end %>
  </div>
<% end %>
</div>

<div class="my-4">
  <%= link_to 'New Project', new_project_path, class: "btn btn-default" %>
</div>
Enter fullscreen mode Exit fullscreen mode

For now, I have some placeholder content for where tasks will be. We'll tackle that logic next.

You may notice the Edit project link. Right now it's only displayed if a given user authored the project. So if that's not quite clear, imagine you created the project. You would be the only one capable of editing it when signed in.

project-show-before

Adding the task form

To make the experience nicer I'd prefer to add our task form within the Project show view itself. This requires a bit of work to make happen.

First, we need to update our projects_controller.rb file to include an instance of a new Task object. Since we're targeting the show view for projects we will add it inside the show method in the controller.

# app/controllers/projects_controller.rb

class ProjectsController < ApplicationController
  before_action :authenticate_user!
  before_action :set_project, only: [:show, :edit, :update, :destroy]

  # GET /projects
  # GET /projects.json
  def index
    @projects = Project.all
  end

  # GET /projects/1
  # GET /projects/1.json
  def show
    @task = Task.new # add this line
  end
  ...

 end
Enter fullscreen mode Exit fullscreen mode

This allows us to instantiate a new form object on the page using some nesting relative to our routing. We'll render it as a partial on the projects show view and pass in the new @task instance variable.

<!-- app/views/projects/show.html.erb-->
<div class="max-w-4xl px-10 pt-10 pb-24 mx-auto mt-10 bg-white border rounded shadow-lg">
  <div class="flex flex-wrap items-center justify-between">
    <h1 class="mb-4 text-4xl font-bold"><%= @project.title %></h1>
    <% if @project.user == current_user %>
      <%= link_to 'Edit project', edit_project_path(@project), class: "underline" %>
    <% end %>
  </div>

  <div class="text-lg font-light leading-relaxed text-gray-600">
    <p class="font-bold text-gray-900">Description:</p>
    <%= @project.description %>
  </div>

  <h3 class="pb-3 my-6 text-2xl font-bold border-b">Tasks</h3>

  <%= render "tasks/form", task: @task %>
</div>
Enter fullscreen mode Exit fullscreen mode

And the form itself gets a few updates

<!-- app/views/tasks/_form.html.erb-->

<%= form_with(model: [@project, task], local: true) do |form| %>
  <% if task.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(task.errors.count, "error") %> prohibited this task from being saved:</h2>

      <ul>
        <% task.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="mb-6">
    <%= form.label :body, "Add a task", class: "label" %>
    <%= form.text_area :body, class: "input" %>
  </div>

  <%= form.submit class: "btn btn-default" %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Notice the model: [@project, task] addition. This builds a URL for us based on our routing defined in config/routes.rb. If you view source on the form you'll see what eventually gets output.

<form action="/projects/1/tasks" accept-charset="UTF-8" method="post"><input type="hidden" name="authenticity_token" value="SxRjZOtGRYxXqL2V2bWW74RRCi/1kDMQADabKLqgOZMVIOWhElh0izxnWmyKw1OTQdeoQEKbvN5qSNrE5ObBcw==">

  <div class="mb-6">
    <label class="label" for="task_body">Add a task</label>
    <textarea class="input" name="task[body]" id="task_body"></textarea>
  </div>

  <input type="submit" name="commit" value="Create Task" class="btn btn-default" data-disable-with="Create Task">
</form>
Enter fullscreen mode Exit fullscreen mode

Adding the [@project, task] bit of code ultimately allows us to use the form on the project show view. Once this is added we get a new task form!

task-form

Updating the tasks controller

If you tried to create a new task up until this point you might be getting some errors about task_path not being present. Our original scaffold hasn't accounted for our nested routing so the URL helpers in the tasks_controller.rb file needs some love.

# app/controllers/tasks_controller.rb

class TasksController < ApplicationController
  before_action :authenticate_user!
  before_action :set_task, only: [:show, :edit, :update, :destroy]
  before_action :set_project

  def index
    @tasks = Task.all
  end

  def edit
  end

  def create
    @task = @project.tasks.create(task_params)

    respond_to do |format|
      if @task.save
        format.html { redirect_to project_path(@project), notice: 'Task was successfully created.' }
      else
        format.html { redirect_to project_path(@project) }
      end
    end
  end

  def update
    respond_to do |format|
      if @task.update(task_params)
        format.html { redirect_to project_path(@project), notice: 'Task was successfully updated.' }
      else
        format.html { render_to project_path(@project) }
      end
    end
  end

  def destroy
    @task.destroy
    respond_to do |format|
      format.html { redirect_to tasks_url, notice: 'Task was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  private

    def set_project
      @project = Project.find(params[:project_id])
    end

    def set_task
      @task = Task.find(params[:id])
    end

    def task_params
      params.require(:task).permit(:body, :project_id, :complete)
    end
end
Enter fullscreen mode Exit fullscreen mode

There's a good chunk that changed here. Because of our nested routing and embedded task form we need to think about how to relate tasks to projects when they are created. We do this by first finding there Project based on the parameter :project_id. This sets the stage for the creation of tasks within the Project object so they now relate.

I removed the show action entirely here as the Project will be where our tasks live. Following that I appended a before_action called set_project which runs this code before each action in the controller:

def set_project
  @project = Project.find(params[:project_id])
end
Enter fullscreen mode Exit fullscreen mode

You'll find this at the bottom of the file following the private declaration which means we only want the controller to have access to this method internally, not externally. You couldn't run ProjectsController.set_project for example.

The create action is where most of the magic happens here.

def create
    @task = @project.tasks.create(task_params)

    respond_to do |format|
      if @task.save
        format.html { redirect_to project_path(@project), notice: 'Task was successfully created.' }
      else
        format.html { redirect_to project_path(@project) }
      end
    end
  end
Enter fullscreen mode Exit fullscreen mode

We use the new @project instance variable to get its associated tasks and create a new one by calling create. We pass in the parameters found also in the private methods to whitelist the fields we want.

Finally, the redirect_to path helpers get an update to just be project_path(@project) since we just want to create a task and head back to the project. Further improvements here could be to make this entire flow ajax driven so you don't even need the redirect but that's beyond the scope of this tutorial.

Rendering tasks

Upon creating tasks we want them to render above the "Add a task" form. I'll update the project show page to reflect this state.

<!-- app/views/projects/show.html.erb-->
<div class="max-w-4xl px-10 pt-10 pb-24 mx-auto mt-10 bg-white border rounded shadow-lg">
  <div class="flex flex-wrap items-center justify-between">
    <h1 class="mb-4 text-4xl font-bold"><%= @project.title %></h1>
    <% if @project.user == current_user %>
      <%= link_to 'Edit project', edit_project_path(@project), class: "underline" %>
    <% end %>
  </div>

  <div class="text-lg font-light leading-relaxed text-gray-600">
    <p class="font-bold text-gray-900">Description:</p>
    <%= @project.description %>
  </div>

  <h3 class="pb-3 my-6 text-2xl font-bold border-b">Tasks</h3>

  <ul class="mb-6 leading-relaxed">
  <% @project.tasks.each do |task| %>
    <li>
      <%= check_box_tag "complete", task.complete %>
      <%= task.body %>
    </li>
  <% end %>
  </ul>

  <%= render "tasks/form", task: @task %>
</div>
Enter fullscreen mode Exit fullscreen mode

When a new task is created it is rendered below the Tasks heading. Each task has a checkbox field associated with the complete column. We'll use a bit of AJAX + Stimulus.js to make updates to the database once a task is checked. We can also restyle the task body text to have an alternate state if checked off.

Completing tasks

When a task is complete to change the complete boolean column in the database to be true. I also want to render an alternate style for the checkbox when completed. To make things a little easier let's leverage Stimulus.js + a bit of AJAX to get this done.

Run the following to install Stimulus.

$ bundle exec rails webpacker:install:stimulus 
Enter fullscreen mode Exit fullscreen mode

Next, I'll rename the demo hello_controller.js file that lives inside app/javascript/controllers to tasks_controller.js.

In the project show view, I'll update the mark up to accommodate for the new Stimulus controller.

 <!-- app/views/projects/show.html.erb-->
<div class="max-w-4xl px-10 pt-10 pb-24 mx-auto mt-10 bg-white border rounded shadow-lg">
  <div class="flex flex-wrap items-center justify-between">
    <h1 class="mb-4 text-4xl font-bold"><%= @project.title %></h1>
    <% if @project.user == current_user %>
      <%= link_to 'Edit project', edit_project_path(@project), class: "underline" %>
    <% end %>
  </div>

  <div class="text-lg font-light leading-relaxed text-gray-600">
    <p class="font-bold text-gray-900">Description:</p>
    <%= @project.description %>
  </div>

  <h3 class="pb-3 my-6 text-2xl font-bold border-b">Tasks</h3>

  <ul id="<%= dom_id(@project) %>_tasks" class="mb-6 leading-relaxed" data-controller="tasks">
    <%= render @project.tasks %>
  </ul>

  <%= render "tasks/form", task: @task %>
</div>

Enter fullscreen mode Exit fullscreen mode

I went ahead and rendered a collection of tasks to clean up things here. This essentially renders a new partial called "tasks/task" and passes an instance of task down for use in the partial. This is heavy on the "contentions" side of Rails.

Here's that partial

<!-- app/views/tasks/_task.html.erb-->

<li>
  <label for="task_<%= task.id %>" data-action="change->tasks#toggleComplete" data-task-url="<%= project_task_url(task.project, task) %>" data-task-id="<%= task.id %>" class="<%= "completed" if task.complete? %>">
    <input type="checkbox" id="task_<%= task.id %>">
    <%= task.body %>
  </label>
</li>
Enter fullscreen mode Exit fullscreen mode

This file sets up most of the data and logic we need for the stimulus controller. The general idea is to send a PATCH AJAX request when a task is checked or unchecked. In the backend, this will toggle our boolean column on the tasks table. Additionally, we append a new class to checked off tasks to create a more visual queue that is complete.

Inside of my _forms.scss file I added a simple class with some tailwind @apply statements

/* app/javascript/stylesheets/_form.scss */

.completed {
  @apply line-through italic opacity-50;
}
Enter fullscreen mode Exit fullscreen mode

And in the Stimulus controller, we add a bit of code so that when the checkbox is checked we can toggle state of the checkbox tag and label.

// app/javascript/controllers/tasks_controller.js

import { Controller } from "stimulus"

export default class extends Controller {
  toggleComplete(event) {
    event.preventDefault()

    const taskId = event.target.parentElement.dataset.taskId
    let data

    if (event.target.checked == true)
      data = `task[id]=${taskId}&task[complete]=true`
    else {
      data = `task[id]=${taskId}&task[complete]=false`
    }

    Rails.ajax({
      url: event.target.parentElement.dataset.taskUrl,
      type: 'patch',
      data: data,
      error: (errors) => {
        console.log(errors)
      },
      success: (response) => {
        event.target.parentElement.classList.toggle('completed')
      }
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

There's some magic going on here. We define an action inside out partial change->tasks#toggleComplete. That event gets sent to our controller for use as we need it. I pass the task id through by using a data attribute that allows me to build a URL to send a PATCH request to the server. With Rails, PATCH typically means the update action since it's a RESTful pattern. Along with the PATCH request, we send a data payload that contains a formatted URL Rails controllers can absorb easily. If we get a successful response we can update the state of the label on the checkbox to a completed or incomplete state.

To use the Rails namespaced JS object here we need to modify the packs/applications.js a touch.

// app/javascript/packs/application.js

window.Rails = require("@rails/ujs")
require("turbolinks").start()
require("@rails/activestorage").start()
require("channels")

import "stylesheets/application"
import "controllers"

Rails.start()
Enter fullscreen mode Exit fullscreen mode

Notice how I bind the @rails/ujs require statement to a new method on the window object.

Update the tasks controller

To really update the data layer our controller needs some more logic.

class TasksController < ApplicationController
  ...

  def update
    @task = @project.tasks.find(params[:task][:id])

    respond_to do |format|
      if params[:task][:complete] == true
        @task.update(complete: true)
      end

      if @task.update(task_params)
        format.json { render :show, status: :ok, location: project_path(@project) }
      else
        format.html { render_to project_path(@project) }
      end
    end
  end

  ...

  private

    def set_project
      @project = Project.find(params[:project_id])
    end

    def set_task
      @task = Task.find(params[:id])
    end

    def task_params
      params.require(:task).permit(:body, :project_id, :complete)
    end
end
Enter fullscreen mode Exit fullscreen mode

We'll focus on the update method here. I need to find a given task that's being interacted with on a given project. To do that we first need to find the Project and its tasks. From the parameters we send through via AJAX, we can hook into the appropriate task id to find and manipulate.

We can validate if the task's completed state is true or not and update the complete column accordingly.

Once updated I decided to render a JSON response. This might throw an error for you if you don't update the _task.json.jbuilder file.

# app/views/tasks/_task.json.jbuilder
json.extract! task, :id, :body, :project_id, :created_at, :updated_at, :complete
json.url project_task_url(task.project, task, format: :json)
Enter fullscreen mode Exit fullscreen mode

Now if you wanted to could console.log(response) on the success callback to see the JSON in view

Object { id: 2, body: "test 2", project_id: 1, created_at: "2020-06-04T15:56:57.234Z", updated_at: "2020-06-04T21:02:10.998Z", complete: true, url: "http://localhost:3000/projects/1/tasks/2.json" }
Enter fullscreen mode Exit fullscreen mode

If that all worked you might see something similar to this. Yes!

Adding Project Users

I would argue having a project management app without a team isn't entirely necessary. While we could go down the rabbit hole of building a complete team backed application, I want to take the simple route and make use of our ProjectUser model. This model will allow us to tie multiple users to a project for the purposes of this tutorial.

We need to add a has_many :through association to both our user and project models.

# app/models/project.rb

class Project < ApplicationRecord
  belongs_to :user
  has_many :users, through: :project_users
  has_many :project_users
  has_many :tasks, dependent: :destroy
end
Enter fullscreen mode Exit fullscreen mode
# app/models/user.rb

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  has_many :project_users
  has_many :projects, through: :project_users
end
Enter fullscreen mode Exit fullscreen mode

We use through: :project_users to tie multiple users to multiple projects.

Refreshing the app you may get a new error after this change. Because a project doesn't belong to a single user any more we need to update a few things in both the Project show view and projects_controller.rb file.

Rather than do too much logic in our views I'll add a method on the model layer.

# app/models/project.rb

class Project < ApplicationRecord
  has_many :project_users
  has_many :users, through: :project_users
  has_many :tasks, dependent: :destroy

  def author(user)
    self.project_users.where(user: user)
  end
end
Enter fullscreen mode Exit fullscreen mode

And update the view:

<div class="max-w-4xl px-10 pt-10 pb-24 mx-auto mt-10 bg-white border rounded shadow-lg">
  <div class="flex flex-wrap items-center justify-between">
    <h1 class="mb-4 text-4xl font-bold"><%= @project.title %></h1>
    <% if @project.author(current_user) %>
      <%= link_to 'Edit project', edit_project_path(@project), class: "underline" %>
    <% end %>
  </div>

  <div class="text-lg font-light leading-relaxed text-gray-600">
    <p class="font-bold text-gray-900">Description:</p>
    <%= @project.description %>
  </div>

  <h3 class="pb-3 my-6 text-2xl font-bold border-b">Tasks</h3>

  <ul id="<%= dom_id(@project) %>_tasks" class="mb-6 leading-relaxed" data-controller="tasks">
    <%= render @project.tasks %>
  </ul>

  <%= render "tasks/form", task: @task %>
</div>
Enter fullscreen mode Exit fullscreen mode

Now we need a form to actually assign project users to a project. The easiest way is to just append that logic to the project form itself.

<!-- app/views/projects/_form.html.erb-->

<%= form_with(model: project, local: true) do |form| %>
  <% if project.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(project.errors.count, "error") %> prohibited this project from being saved:</h2>

      <ul>
        <% project.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="mb-6">
    <%= form.label :title, class: "label" %>
    <%= form.text_field :title, class: "input" %>
  </div>

  <div class="mb-6">
    <%= form.label :description, class: "label" %>
    <%= form.text_area :description, class: "input" %>
  </div>

    <!-- add the folowing 3 lines -->
  <div class="mb-6">
    <%= collection_check_boxes(:project, :user_ids, User.all, :id, :name) %>
  </div>

  <%= form.submit class: "btn btn-default" %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

This new helper called collection_check_boxes outputs all users and will assign their id to the project object when saved. We still need to permit the new attribute in the controller.

# app/controllers/projects_controller.rb

# Only allow a list of trusted parameters through.
def project_params
  params.require(:project).permit(:title, :description, user_ids: [])
end
Enter fullscreen mode Exit fullscreen mode

Note the addition of user_ids: [] here. Because we could add more than one Project User at once we need to permit an array value.

Based on the number of users in your app you can display each using the User.all query. To enhance this you might want to scope users to a current team/project etc... but that's not our focus here.

When creating a new project you should now see a set of checkboxes that when selected will be the users associated with the project.

Displaying Project Users

Now that our form logic is sorted we should display any saved project users on the front-end

<!-- app/views/projects/show.html.erb-->

<div class="max-w-4xl px-10 pt-10 pb-24 mx-auto mt-10 bg-white border rounded shadow-lg">
  <div class="flex flex-wrap items-center justify-between">
    <h1 class="mb-4 text-4xl font-bold"><%= @project.title %></h1>
    <% if @project.author(current_user) %>
      <%= link_to 'Edit project', edit_project_path(@project), class: "underline" %>
    <% end %>
  </div>

  <div class="text-lg font-light leading-relaxed text-gray-600">
    <p class="font-bold text-gray-900">Description:</p>
    <%= @project.description %>
  </div>

  <% if @project.users.any? %>
    <h3 class="pb-3 my-6 text-2xl font-bold border-b">Collaborators</h3>

    <ul class="pl-4 list-disc">
      <% @project.users.each do |user| %>
        <li><%= user.name %></li>
      <% end %>
    </ul>
  <% end %>

  <h3 class="pb-3 my-6 text-2xl font-bold border-b">Tasks</h3>

  <ul id="<%= dom_id(@project) %>_tasks" class="mb-6 leading-relaxed" data-controller="tasks">
    <%= render @project.tasks %>
  </ul>

  <%= render "tasks/form", task: @task %>
</div>
Enter fullscreen mode Exit fullscreen mode

Here I've added a conditional to first check if any project users exist. If so we'll display a simple ordered list containing their name. This could easily be enhanced to include an avatar or perhaps links to social media or profile pages within the app.

Mailers

Finally, we've reached the email stage of the tutorial. The goal here will be to add email notifications for project users who are assigned to a project. I'd like to send notifications for the following events that occur in-app:

  • A user (you) gets added to a project
  • A task is created
  • A task is completed

Because we want to give a user the option to turn off these notifications let's go ahead and add some fields to the database to account for each case above.

$ rails g migration add_email_notifications_to_users notify_when_added_to_project:boolean notify_when_task_created:boolean notify_when_task_completed:boolean
Enter fullscreen mode Exit fullscreen mode

I'll generate a migration for our users' table that is essentially 3 boolean fields. Before you migrate this we need to set some defaults by hand.

My migration file looks like this:

# db/migrate/XXXXXXXXXXXXXX_add_email_notifications_to_users.rb

class AddEmailNotificationsToUsers < ActiveRecord::Migration[6.0]
  def change
    add_column :users, :notify_when_added_to_project, :boolean, default: true
    add_column :users, :notify_when_task_created, :boolean, default: true
    add_column :users, :notify_when_task_completed, :boolean, default: true
  end
end
Enter fullscreen mode Exit fullscreen mode

At the end of each add_column line I append , default: true. We'll default these options to true.

$ rails db:migrate

# should return something like this
== XXXXXXXXXXXXXX AddEmailNotificationsToUsers: migrating =====================
-- add_column(:users, :notify_when_added_to_project, :boolean, {:default=>true})
   -> 0.0032s
-- add_column(:users, :notifiy_when_task_created, :boolean, {:default=>true})
   -> 0.0014s
-- add_column(:users, :notify_when_task_completed, :boolean, {:default=>true})
   -> 0.0010s
== XXXXXXXXXXXXXX AddEmailNotificationsToUsers: migrated (0.0058s) ============
Enter fullscreen mode Exit fullscreen mode

With this in place, we can generate our first mailer

$ rails g mailer Project user_added_to_project
Enter fullscreen mode Exit fullscreen mode

This generation will create a ProjectMailer ruby class a method called user_added_to_project.

Next, we need a mailer for tasks so in comes another migration

$ rails g mailer Task task_created task_completed
Enter fullscreen mode Exit fullscreen mode

Here we create a mailer and two new methods within called task_created and task_completed.

Generators are so handy in the fact that they create our views, mailer classes, and mailer previews all at once. There is some work to be done still but it's such a nice experience to be able to generate these things on the fly.

Task Created Email

When a task is created we want to send an email using a background job. My kickoff_tailwind template already has Sidekiq (my favorite tool for background jobs) installed. I won't walk through the installation of Sidekiq here but be sure to check out the docs.

With the mailer, I want to be able to reference our project inside our template. This should be pretty simple to pass through from the controller itself.

# app/controllers/tasks_controller.rb

class TasksController < ApplicationController
 ...
 def create
    @task = @project.tasks.create(task_params)

    respond_to do |format|
      if @task.save
        (@project.users.uniq - [current_user]).each do |user|
          TaskMailer.with(task: @task, user: user, author: current_user).task_created.deliver_later
        end 
        format.html { redirect_to project_path(@project), notice: 'Task was successfully created.' }
      else
        format.html { redirect_to project_path(@project) }
      end
    end
  end

 ...
 end
Enter fullscreen mode Exit fullscreen mode

We add a single line to the create action of the tasks_controller.rb file. Assuming a task is saved, we'll kick off our task_created method within the TaskMailer class by looping through all the users associated with the project except the current user. We won't send this email to the person creating the task, to begin with.

The deliver_later addition signals ActiveJob (or Sidekiq in our case) to initialize a background job for this work.

Using the with property we can pass parameters through to the mailer class for use later. Here I pass both the task and user based on the array of project users available. I also pass an instance of the current_user helper method we get from Devise through so the recipients know who created a task originally.

Sidekiq will then add it to a queue to be performed "later". The perks of doing this weigh-in more when your app scales to a larger size. If thousands of users are creating tasks at once and you aren't queuing up the emails being sent, your servers would practically melt!

The task_created logic

Now in our mailer, we can hook into the parameters being passed from the controller to pass the data we need down to our email views.

I modified the task_created method to look like the following:

# app/mailers/task_mailer.rb

class TaskMailer < ApplicationMailer
 def task_created
    @task = params[:task]
    @user = params[:user]
    @author = params[:author]

    mail to: @user.email, subject: "#{@task.project.title}: A new task was created by #{@author.name}"
  end

  def task_completed
    @greeting = "Hi"

    mail to: "to@example.org"
  end
end
Enter fullscreen mode Exit fullscreen mode

Here we hook into those two parameters we originally passed as well as set an instance variable for the project itself for use in the mailer views.

The easiest way to verify this works is to both send an email (by creating a new task) and also using the built-in Rails mailer previews.

# test/mailers/previews/task_mailer_preview.rb

# Preview all emails at http://localhost:3000/rails/mailers/task_mailer
class TaskMailerPreview < ActionMailer::Preview

  # Preview this email at http://localhost:3000/rails/mailers/task_mailer/task_created
  def task_created
    TaskMailer.with(task: Task.last, user: User.first, author: User.last).task_created
  end

  # Preview this email at http://localhost:3000/rails/mailers/task_mailer/task_completed
  def task_completed
    TaskMailer.task_completed
  end
end
Enter fullscreen mode Exit fullscreen mode

Following the commented out links you will see a primitive email view of our mailers. We need to add our logic still there. Notice the line:

TaskMailer.with(task: Task.last, user: User.first).task_created
Enter fullscreen mode Exit fullscreen mode

We use some dummy data here to render something in the views. Much like we did in the controller we pass parameters here but use actual data. We also don't need to work on background jobs entirely.

<!-- app/views/task_created.html.erb-->

<p>Hi <%= @user.name %>,</p>

<p><%= @author.name %> added a new task for <%= link_to @task.project.title, project_url(@task.project) %>:</p>

<p>Task:</p>
<p style="padding: 10px; background-color: #efefef;">
  <%= @task.body %>
</p>

<%= link_to "View the task", project_url(@task.project), target: :_blank %>
Enter fullscreen mode Exit fullscreen mode

The views are VERY basic but I wanted to show some of the data so we show the task body, where it was created, and offer a link to the project.

The task_completed logic

The task_completed method will be very similar to the task_created. We'll just adjust the messaging and placement of the initial reference to the TaskMailer class in the tasks_controller.

# app/controllers/tasks_controller.rb
...
def update
  @task = @project.tasks.find(params[:task][:id])

  respond_to do |format|
    if params[:task][:complete] == true
      @task.update(complete: true)

      ## add the three lines below
      (@project.users.uniq - [current_user]).each do |user|
        TaskMailer.with(task: @task, user: user, author: current_user).task_completed.deliver_later
      end
    end

    if @task.update(task_params)
      format.json { render :show, status: :ok, location: project_path(@project) }
    else
      format.html { render_to project_path(@project) }
    end
  end
end
...
Enter fullscreen mode Exit fullscreen mode

Here we email all project users if a task is completed minus the user completing the task. Notice how everything is the same as the task_created method except now we use task_completed.

In the mailer, we update accordingly.

# app/mailers/task_mailer.rb

class TaskMailer < ApplicationMailer
  def task_created
    @task = params[:task]
    @user = params[:user]
    @author = params[:author]

    mail to: @user.email, subject: "#{@task.project.title}: A new task was created by #{@author.name}"
  end

  def task_completed
    @task = params[:task]
    @user = params[:user]
    @author = params[:author]

    mail to: @user.email, subject: "#{@task.project.title}: A task was completed by #{@author.name}"
  end
end
Enter fullscreen mode Exit fullscreen mode

And the associated view

<!-- app/views/task_completed.html.erb-->

<p>Hi <%= @user.name %>,</p>

<p><%= @author.name %> completed a task for <%= link_to @task.project.title, project_url(@task.project) %>:</p>

<p>Task:</p>
<p style="padding: 10px; background-color: #efefef;">
  <%= @task.body %>
</p>

<%= link_to "View the task", project_url(@task.project), target: :_blank %>
Enter fullscreen mode Exit fullscreen mode

I'll remove the text-based view templates for now. You're welcome to use them if you'd like to support both text + HTML mailer views.

The user_added_to_project mailer

Wrapping up with our mailer views and logic we'll tackle the user_added_to_project mailer next.

  # app/controllers/projects_controller.rb

  ....
  def create
    @project = Project.new(project_params)
    @project.user = current_user

    respond_to do |format|
      if @project.save

        if @project.users.any?
          (@project.users.uniq - [current_user]).each do |user|
            ProjectMailer.with(project: @project, user: user, author: current_user).user_added_to_project.deliver_later
          end
        end

        format.html { redirect_to @project, notice: 'Project was successfully created.' }
        format.json { render :show, status: :created, location: @project }
      else
        format.html { render :new }
        format.json { render json: @project.errors, status: :unprocessable_entity }
      end
    end
  end

  ...
Enter fullscreen mode Exit fullscreen mode

Inside our project controller, we append a bit more logic but still borrow from some of the logic we added to our task mailers. When a new project is created we:

  1. Loop through all project users minus the current user
  2. For each user, we kick off an email on the ProjectMailer class.
  3. We pass along parameters include project, project_user, and the author of the action
  4. Call deliver_later to throw it in a queue using Sidekiq behind the scenes.

In our mailer account for the parameters and add a subject.

# app/mailers/project_mailer.rb

class ProjectMailer < ApplicationMailer
  def user_added_to_project
    @user = params[:user]
    @project = params[:project]
    @author = params[:author]

    mail to: @user.email, subject: "#{@author.name} added you to #{@project.title}"
  end
end
Enter fullscreen mode Exit fullscreen mode

And our preview file:

# test/mailers/previews/project_mailer_preview.rb

# Preview all emails at http://localhost:3000/rails/mailers/project_mailer
class ProjectMailerPreview < ActionMailer::Preview

  # Preview this email at http://localhost:3000/rails/mailers/project_mailer/user_added_to_project
  def user_added_to_project
    ProjectMailer.with(project: Project.last, user: User.first, author: User.last).user_added_to_project
  end

end
Enter fullscreen mode Exit fullscreen mode

And finally the view:

<!-- app/views/project_mailer/user_added_to_project.html.erb-->
<p>Hi <%= @user.name %>,</p>

<p><%= @author.name %> added you to a new project called <%= link_to @project.title, project_url(@project) %></p>

<%= link_to "View the project", project_url(@project), target: :_blank %>
Enter fullscreen mode Exit fullscreen mode

Update User registration view

Remember when we added those boolean values to our users' table? We should probably make those accessible to users when visiting their profile.

Within my main application layout file, I want to display a "Profile" link so it's easier to edit a given user profile.

<!-- app/views/layouts/application.html.erb == around line 45-47 == -->
<% if user_signed_in? %>
  <%= link_to "Profile", edit_user_registration_path, class:"btn btn-default mb-2 lg:mr-2 lg:mb-0 block" %>
  <%= link_to "Log out", destroy_user_session_path, method: :delete, class:"btn btn-default mb-2 lg:mr-2 lg:mb-0 block" %>
<% else %>
  <%= link_to "Login", new_user_session_path, class:"btn btn-default mb-2 lg:mr-2 lg:mb-0 block" %>
<%= link_to "Sign Up", new_user_registration_path, class:"btn btn-default block" %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Now you can visit the path we'll be adding the updated options too.

Adding the fields to the user registration form that comes with Devise is relatively straight forward.

<!-- app/views/devise/registrations/edit.html.erb -->
<% content_for :devise_form do %>
  <h2 class="pt-4 mb-8 text-4xl font-bold heading">Edit <%= resource_name.to_s.humanize %></h2>

  <%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %>

    <%= render "devise/shared/error_messages", resource: resource %>

    <div class="mb-6">
      <%= f.label :username, class:"label" %>
      <%= f.text_field :username, autofocus: true, class:"input" %>
    </div>

    <div class="mb-6">
      <%= f.label :name, class:"label" %>
      <%= f.text_field :name, class:"input" %>
    </div>

    <div class="mb-6">
      <%= f.label :email, class:"label" %>
      <%= f.email_field :email, autocomplete: "email", class:"input" %>
    </div>

    <div class="mb-6">
      <% if devise_mapping.confirmable? && resource.pending_reconfirmation? %>
        <div>Currently waiting confirmation for: <%= resource.unconfirmed_email %></div>
      <% end %>
    </div>

    <div class="mb-6">
      <%= f.label :password, class:"label" %>
      <%= f.password_field :password, autocomplete: "new-password", class:"input" %>
      <p class="pt-1 text-sm italic text-grey-dark"> <% if @minimum_password_length %>
        <%= @minimum_password_length %> characters minimum <% end %> (leave blank if you don't want to change it) </p>

    </div>

    <div class="mb-6">
      <%= f.label :password_confirmation, class: "label" %>
      <%= f.password_field :password_confirmation, autocomplete: "new-password", class: "input" %>
    </div>

    <div class="mb-6">
      <%= f.label :current_password, class: "label" %>
      <%= f.password_field :current_password, autocomplete: "current-password", class: "input" %>
      <p class="pt-2 text-sm italic text-grey-dark">(we need your current password to confirm your changes)</p>
    </div>

    <hr class="mt-6 mb-3 border" />

    <h3 class="mb-4 text-xl font-bold heading">Email preferences</h3>

    <div class="mb-6">
      <%= f.check_box :notify_when_added_to_project %>
      <%= f.label :notify_when_added_to_project %>
    </div>

    <div class="mb-6">
      <%= f.check_box :notify_when_task_created %>
      <%= f.label :notify_when_task_created %>
    </div>

    <div class="mb-6">
      <%= f.check_box :notify_when_task_completed %>
      <%= f.label :notify_when_task_completed %>
    </div>

    <div class="mb-6">
      <%= f.submit "Update", class: "btn btn-default" %>
    </div>
    <% end %>

    <hr class="mt-6 mb-3 border" />

    <h3 class="mb-4 text-xl font-bold heading">Cancel my account</h3>

    <div class="flex items-center justify-between">
      <div class="flex-1"><p class="py-4">Unhappy?</p></div>

      <%= button_to "Cancel my account", registration_path(resource_name), data: { confirm: "Are you sure?" }, method: :delete, class: "btn btn-default" %>
    </div>

<% end %>

<%= render 'devise/shared/form_wrap' %>
Enter fullscreen mode Exit fullscreen mode

This file is modified for my kickoff_tailwind template but the big change here is the addition of the three boolean fields which are now checkboxes.

We need to permit these fields in our application_controller next so they actually save.

# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception

  before_action :configure_permitted_parameters, if: :devise_controller?

  protected

    def configure_permitted_parameters
      devise_parameter_sanitizer.permit(:sign_up, keys: [:username, :name])
      devise_parameter_sanitizer.permit(:account_update, keys: [:username, :name, :notify_when_added_to_project, :notify_when_task_created, :notify_when_task_completed])
    end
end
Enter fullscreen mode Exit fullscreen mode

With Devise you can permit this way. I find it's the easiest.

Adding a unsubscribe link to emails

I want to make it super simple for someone to opt-out of receiving future notifications right from the email. Typically, emails you receive have an "Unsubscribe" link to opt-out of further communications. Some companies abuse this privilege and make you sign in to really change any setting when you click "Unsubscribe". I want to be able to bypass the sign-in stage and just opt the user out. We can accomplish this with a little elbow grease.

Making things more secure

Simply giving any user a direct link to edit another user's account settings doesn't sound like a great idea. Instead, we'll generate a random secure hash string to help keep things more secure. We'll store this has on each user so we have a way to find them during this public query. To do this we need to add a column to the users' table.

$ rails g migration add_unsubscribe_hash_to_users unsubscribe_hash:string
$ rails db:migrate
Enter fullscreen mode Exit fullscreen mode

Now in the user model, we'll use a callback function to add a newly generated number to the user model before a new user gets created.

# app/models/user.rb

class User < ApplicationRecord
  before_create :add_unsubscribe_hash
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  has_many :project_users
  has_many :projects, through: :project_users

  private
    def add_unsubscribe_hash
      self.unsubscribe_hash = SecureRandom.hex
    end
end
Enter fullscreen mode Exit fullscreen mode

Notice the before_create :add_unsubscribe_hash callback declaration. Here we call the private method at the bottom of the file to generate and assign a SecureRandom.hex value to the unsubscribe_hash column on the users table in the database.

This only happens when a new user is created so if you have existing users in your database you need to run a few commands in the console.

$ rails c
> User.all.each { |user| user.update(unsubscribe_hash: SecureRandom.hex) }
Enter fullscreen mode Exit fullscreen mode

We loop through all users and update the unsubscribe_hash column to now include the SecureRandom.hex code. This automatically updates and saves each user in your database.

Unsubscribe Routing

We need a new path in our app to handle logic once a user subscribes as well as append the hashes to the link. In my routes file I added the following:

 # config/routes.rb

 match "users/unsubscribe/:unsubscribe_hash" => "emails#unsubscribe", as: "unsubscribe", via: :all
Enter fullscreen mode Exit fullscreen mode

We're creating a custom path for unsubscribes that essentially points to an emails_controller.rb file where an unsubscribe method would live. You may need to restart your server at this point.

We don't have this controller yet so let's create it.

# app/controllers/emails_controller.rb

class EmailsController < ApplicationController
  def unsubscribe
  end
end
Enter fullscreen mode Exit fullscreen mode

We'll also need a view to go with this as a "Success" style of the page the user lands on when clicking "Unsubscribe" from a given email

<!-- app/views/emails/unsubscribe.html.erb-->

<div class="max-w-xl mx-auto mt-16">
  <div class="px-6 py-10 text-center border border-gray-200 rounded shadow-lg bg-white-100">
    <h1 class="pt-4 text-2xl font-display">Successfully unsubscribed</h1>
    <p class="mb-6">You will no longer receive updates about <%= @reason %></p>
    <p class="text-sm text-gray-600">You can always opt back in within your <%= link_to "profile settings", edit_user_registration_path, class: "underline" %>.</p>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Back in our controller, we need to add some logic to account for which email unsubscribe request comes through. I'll use parameters in each "Unsubscribe" link to help make this process easy. It makes the most sense to use a partial for this repeated process in each mailer view. Inside app/views I'll create a new shared folder which will house the following partial.

<!-- app/views/shared/_unsubscribe_link.html.erb -->
<p style="font-size: 14px; margin-top: 16px; Margin-top: 16px;">
  <%= link_to "Turn this notification off", unsubscribe_url(@user.unsubscribe_hash, subscription: subscription_type), style: "color: #bbbbbb;", target: :_blank %>
</p>
Enter fullscreen mode Exit fullscreen mode

We have a new unsubscribe_url helper thanks to our recent routing updates. Within that, I hook into the @user instance variable which will account for each user we pass through. Finally, the important part is adding the subscription parameter here. When we render this partial we can give it a key that I'm calling subscription_type (you can name both of this anything you want). Back in our controller, we can hook into these parameters and conditionally display data.

First, let's update the mailer views:

<!-- app/views/project_mailer/user_added_to_project.html.erb-->
<p>Hi <%= @user.name %>,</p>

<p><%= @author.name %> added you to a new project called <%= link_to @project.title, project_url(@project) %></p>

<%= link_to "View the project", project_url(@project), target: :_blank %>

<%= render "shared/unsubscribe_link", subscription_type: "added_to_project" %>
Enter fullscreen mode Exit fullscreen mode

The new partial generates a link like this:

http://localhost:3000/users/unsubscribe/a46c935c7e8fd02e980761997752aa41?subscription=added_to_project
Enter fullscreen mode Exit fullscreen mode
<!-- app/views/task_mailer/task_created -->
<p>Hi <%= @user.name %>,</p>

<p><%= @author.name %> added a new task for <%= link_to @task.project.title, project_url(@task.project) %>:</p>

<p>Task:</p>
<p style="padding: 10px; background-color: #efefef;">
  <%= @task.body %>
</p>

<%= link_to "View the task", project_url(@task.project), target: :_blank %>

<%= render "shared/unsubscribe_link", subscription_type: "task_created" %>
Enter fullscreen mode Exit fullscreen mode
<!-- app/views/task_mailer/task_completed.html.erb -->
<p>Hi <%= @user.name %>,</p>

<p><%= @author.name %> completed a task for <%= link_to @task.project.title, project_url(@task.project) %>:</p>

<p>Task:</p>
<p style="padding: 10px; background-color: #efefef;">
  <%= @task.body %>
</p>

<%= link_to "View the task", project_url(@task.project), target: :_blank %>

<%= render "shared/unsubscribe_link", subscription_type: "task_completed" %>
Enter fullscreen mode Exit fullscreen mode

Back in the controller we do the logic:

# app/controllers/emails_controller.rb 
class EmailsController < ApplicationController
  def unsubscribe
    user = User.find_by_unsubscribe_hash(params[:unsubscribe_hash])

    case params[:subscription]
      when "added_to_project"
        @reason = "being added to projects"
        user.update(notify_when_added_to_project: false)
      when "task_created"
        @reason = "new tasks"
        user.update(notify_when_task_created: false)
      when "task_completed"
        @reason = "completing tasks"
        user.update(notify_when_task_completed: false)
      end
  end
end
Enter fullscreen mode Exit fullscreen mode

For each subscription type, we take the user instance found by the unsubscribe_hash and update their settings accordingly. In the unsubscribe view we render updated copy based on the subscription parameter that comes through.

Heading back to localhost:3000/rails/mailers, find an email and click the "Turn this notification off" link at the end of each to see the results. My experience looks like the following as I unsubscribe from being notified about completed tasks

successful-unsubscribe

And then double checking my registration settings I can confirm I'm unsubscribed.

confirmed-unsubscribe

Ensuring emails don't get sent

With the bulk of the logic complete we now just need to ensure emails don't get sent based on user email preferences set. We can update our controllers to check this. It might make more sense to extract this logic over time as your app scales but this should work for the purposes of the tutorial.

# app/controllers/projects_controller.rb
...

  def create
    @project = Project.new(project_params)
    @project.user = current_user

    respond_to do |format|
      if @project.save

        (@project.users.uniq - [current_user]).each do |user|
          if user.notify_when_added_to_project?
            ProjectMailer.with(project: @project, user: user, author: current_user).user_added_to_project.deliver_later
          end
        end

        format.html { redirect_to @project, notice: 'Project was successfully created.' }
        format.json { render :show, status: :created, location: @project }
      else
        format.html { render :new }
        format.json { render json: @project.errors, status: :unprocessable_entity }
      end
    end
  end
  ...
Enter fullscreen mode Exit fullscreen mode

In the project controller, we add a simple conditional around the boolean relating to the mailer. We need to check each user's preferences as we loop through all project users.

# app/controllers/tasks_controller.rb
...
def create
    @task = @project.tasks.create(task_params)

    respond_to do |format|
      if @task.save
        (@project.users.uniq - [current_user]).each do |user|
          if user.notify_when_task_created?
           TaskMailer.with(task: @task, user: user, author: current_user).task_created.deliver_later
          end
        end
        format.html { redirect_to project_path(@project), notice: 'Task was successfully created.' }
      else
        format.html { redirect_to project_path(@project) }
      end
    end
  end

  def update
    @task = @project.tasks.find(params[:task][:id])

    respond_to do |format|
      if params[:task][:complete] == true
        @task.update(complete: true)
      end

      if @task.update(task_params)
        (@project.users.uniq - [current_user]).each do |user|
          if user.notify_when_task_completed?
            TaskMailer.with(task: @task, user: user, author: current_user).task_completed.deliver_later
          end
        end
        format.json { render :show, status: :ok, location: project_path(@project) }
      else
        format.html { render_to project_path(@project) }
      end
    end
  end
...
Enter fullscreen mode Exit fullscreen mode

We do the same in the tasks controller for both the create and update methods

Testing email delivery

I like to use mailcatcher when testing emails in my development environment. It's dated but gets the job done. Adding it to your app is quite simple.

$ gem install mailcatcher
Enter fullscreen mode Exit fullscreen mode

And then in your config/environments/development.rb file add the following lines.

Rails.application.configure do
  ...
  config.action_mailer.delivery_method = :smtp
  config.action_mailer.smtp_settings = { :address => "localhost", :port => 1025 }
  ...  
end
Enter fullscreen mode Exit fullscreen mode

You'll want to reboot your server at this point if you haven't. I also went ahead and made another user test the project user's functionality. I recommend doing this for full effect.

In a new terminal window simply run

$ mailcatcher
Enter fullscreen mode Exit fullscreen mode

The client should load on a separate port and be accessible.

Also, If you want to enable the sidekick web UI you need to be an admin user. You can make a user one pretty easily.

I mounted the web UI in the config/routes.rb file. Based on my settings, you can visit localhost:3000/sidekiq only if you're an admin user.

$ rails c
> u = User.first # assuming the first user is your account
> u.admin = true
> u.save
Enter fullscreen mode Exit fullscreen mode

Try creating new projects, new tasks, and completing tasks. I successfully see emails sent to only the other user on the project which is intended. Remember, we don't want to send emails to ourselves if we are performing the action.

If my settings are false within my profile on certain events those emails shouldn't be delivered.

Success!

Finishing up

As a ruby on rails application scales, you can bet mailer logic like we set upstarts to get tedious and cumbersome. A lot of the logic I implore in the controller level could potentially more to the models or additional background jobs. This is all to say, this code isn't perfect but I hope it makes some sense as you start to understand more about mailers, transactional emails, and relating users to resources as groups.

Shameless plug

I have a new course called Hello Rails. Hello Rails is a modern course designed to help you start using and understanding Ruby on Rails fast. If you're a novice when it comes to Ruby or Ruby on Rails I invite you to check out the site. The course will be much like these builds but a super more in-depth version with more realistic goals and deliverables. Download your copy today!

Top comments (0)