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
Outside of that folder, you can create a new app using the template:
$ rails new email_subscriptions -m kickoff_tailwind/template.rb
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
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
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
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
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
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
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>
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 %>
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 %>
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
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
Adding that line should make things right again. Try to create your first project now.
Success!
Create some test data
Let's add some dummy data. Create a couple of projects first.
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>
That gets us here:
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>
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.
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
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>
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 %>
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>
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!
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
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
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
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>
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
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>
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>
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;
}
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')
}
})
}
}
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()
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
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)
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" }
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
# 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
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
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>
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 %>
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
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>
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
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
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) ============
With this in place, we can generate our first mailer
$ rails g mailer Project user_added_to_project
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
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
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
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
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
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 %>
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
...
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
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 %>
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
...
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:
- Loop through all project users minus the current user
- For each user, we kick off an email on the
ProjectMailer
class. - We pass along parameters include project, project_user, and the author of the action
- 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
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
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 %>
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 %>
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' %>
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
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
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
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) }
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
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
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>
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>
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" %>
The new partial generates a link like this:
http://localhost:3000/users/unsubscribe/a46c935c7e8fd02e980761997752aa41?subscription=added_to_project
<!-- 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" %>
<!-- 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" %>
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
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
And then double checking my registration settings I can confirm I'm unsubscribed.
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
...
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
...
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
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
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
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
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)