Rails Messaging Tutorial

By NovaWave Solutions, aka manitoba98

Abstract

This guide aims to be a simple, logical tutorial showing how to develop a simple Rails messaging system with all of the trimmings with Ruby on Rails (v2.0.2). This tutorial is intended for beginner to intermediate Rails users. If you've never used Rails before, I suggest you check out any of the excellent introductions out there.

I do take a few shortcuts here and there (I use inline CSS, for instance). For authentication, you may find it valuable to implement the concept of an administrator or use a before_filter so that users get a login page instead of an error message. I've only implemented this on the Inbox (because users are redirected to it automatically after logout).

This guide was created in response to this post on RailsForum.

Table of Contents

Starting Out

I'll assume that you already have Ruby and Rails 2.0 installed on your machine. If not, get them.

We begin by generating the Rails project. I'm going to open the project in TextMate, my editor of choice, but feel free to use yours.


$ rails messenger
create  
create  app/controllers
create  app/helpers
create  app/models
create  app/views/layouts
create  config/environments
create  config/initializers
create  db
 ...
create  log/production.log
create  log/development.log
create  log/test.log
$ cd messenger
$ mate .

The next step is to create the basic models of our application. We'll have a Message model to represent the message sent, and MessageCopy model to represent each individual recipient's copy of the message. We'll have a User model to represent each person who can potentially send or receive messages. Finally, we'll have a Folder model to represent each folder in which a person can place mail, including an inbox.

Let's generate those now.


$ script/generate model message author_id:integer subject:string body:text
exists  app/models/
exists  test/unit/
exists  test/fixtures/
create  app/models/message.rb
create  test/unit/message_test.rb
create  test/fixtures/messages.yml
create  db/migrate
create  db/migrate/001_create_messages.rb
$ script/generate model message_copy recipient_id:integer message_id:integer folder_id:integer
exists  app/models/
exists  test/unit/
exists  test/fixtures/
create  app/models/message_copy.rb
create  test/unit/message_copy_test.rb
create  test/fixtures/message_copies.yml
exists  db/migrate
create  db/migrate/002_create_message_copies.rb
$ script/generate model folder user_id:integer parent_id:integer name:string
exists  app/models/
exists  test/unit/
exists  test/fixtures/
create  app/models/folder.rb
create  test/unit/folder_test.rb
create  test/fixtures/folders.yml
exists  db/migrate
create  db/migrate/003_create_folders.rb

Note that we still haven't written any code. Now we'll install restful_authentication as our user login system. We'll also install scope_out, acts_as_tree and will_paginate, useful plugins we'll use later on. We'll also migrate the database.


$ script/plugin install http://svn.techno-weenie.net/projects/plugins/restful_authentication
...
$ script/generate authenticated user sessions
exists  app/models/
exists  app/controllers/
exists  app/controllers/
exists  app/helpers/
...
exists  db/migrate
create  db/migrate/004_create_users.rb
route  map.resource :session
route  map.resources :users
$ script/plugin install acts_as_tree
...
$ script/plugin install http://scope-out-rails.googlecode.com/svn/trunk/
...
$ script/plugin install svn://errtheblog.com/svn/plugins/will_paginate
...
$ rake db:migrate
...

As restful_authentication suggests, you should now add the line "include AuthenticatedSystem" in your ApplicationController, located in app/controllers/application.rb. Do so.

Okay, now all of our models have been created. We need to tell Rails how they are linked to each other. Fortunately, Rails makes this absolutely trivial. Update your Message model file (app/models/message.rb):

class Message < ActiveRecord::Base
  belongs_to :author, :class_name => "User"
  has_many :message_copies
  has_many :recipients, :through => :message_copies
end

This, thanks to Rails' simple style, is already fairly legible. Each messages belongs to an author (which is a User). It has many copies, and it has many recipients, which can be found through the copies (since each copy belongs to a single recipient).

Next on the list is MessageCopy. Here it is:

class MessageCopy < ActiveRecord::Base
  belongs_to :message
  belongs_to :recipient, :class_name => "User"
  belongs_to :folder
  delegate   :author, :created_at, :subject, :body, :recipients, :to => :message
end

As you can see, each copy belongs to the original message. It also belongs to a single recipient, and a folder owned by that recipient. That last "delegate" line is probably new to you. It's just syntactic sugar that allows us to "forward" the listed attributes to the copy's original message. That allows us to say "@copy.author" instead of "@copy.message.author". It's shorter; that's all.

Next are the folders. We want the folders to be hierarchical; that is, folders can contain other folders.

class Folder < ActiveRecord::Base
  acts_as_tree
  belongs_to :user
  has_many :messages, :class_name => "MessageCopy"
end

Finally, we'll move on to our User model. We'll define associations for both sent and received messages, as well as folders.

require 'digest/sha1'
class User < ActiveRecord::Base
  has_many :sent_messages, :class_name => "Message", :foreign_key => "author_id"
  has_many :received_messages, :class_name => "MessageCopy", :foreign_key => "recipient_id"
  has_many :folders
  
  # (Autogenerated restful_authentication code remains here)
end

Okay, now that we've got that basic logic established, we need to allow messages to actually be sent. In order to do that, we need to modify the Message model to automatically create MessageCopy models for "distribution" to other users. I'll also add an attr_accessible call for security.

class Message < ActiveRecord::Base
  belongs_to :author, :class_name => "User"
  has_many :message_copies
  has_many :recipients, :through => :message_copies
  before_create :prepare_copies
  
  attr_accessor  :to # array of people to send to
  attr_accessible :subject, :body, :to
  
  def prepare_copies
    return if to.blank?
    
    to.each do |recipient|
      recipient = User.find(recipient)
      message_copies.build(:recipient_id => recipient.id, :folder_id => recipient.inbox.id)
    end
  end
end

So this is our first bit of code proper. I'll go ahead and explain what I've done here. I've added a callback which will execute just before the message is created. It loops through each recipient requested and builds a copy for them, filing it in that person's inbox. Seems logical enough. But we haven't defined the "inbox" method yet. We'll do that now. Just before the pregenerated restful_authentication code, add the following to your User model:

  before_create :build_inbox

  def inbox
    folders.find_by_name("Inbox")
  end

  def build_inbox
    folders.build(:name => "Inbox")
  end

This bit of magic allows us to access the user's "inbox" folder, and ensure that one is created for each user. We're just about ready to get started.

Back to Top

Anchors Aweigh!

Okay, so we're ready to generate our controllers.


$ script/generate controller sent index show new
...
$ script/generate controller messages show
...
$ script/generate controller mailbox show
...

As you can see, we're going to use three separate controllers. The first handles the user's sent messages, and maps on to our Message model. The second handles received messages, and maps on to our MessageCopy model. The last controller manages the user's mailbox, allowing the user to show their inbox and other folders. This does indeed map on to the Folder model. For those of you who know what it is, this is a RESTful design.

The first thing you need to do is add the resource routes. In your config/routes.rb file, change it to the following:

ActionController::Routing::Routes.draw do |map|
  map.resources :users, :sent, :messages, :mailbox
  map.resource :session
  
  # Home route leads to inbox
  map.inbox '', :controller => "mailbox", :action => "index"
  
  # Install the default routes as the lowest priority.
  map.connect ':controller/:action/:id'
  map.connect ':controller/:action/:id.:format'
end

Don't forget to remove public/index.html, or the Rails welcome message will continue to appear. Let's get started with the SentController.

class SentController < ApplicationController

  def index
    @messages = current_user.sent_messages.paginate :per_page => 10, :page => params[:page], :order => "created_at DESC"
  end

  def show
    @message = current_user.sent_messages.find(params[:id])
  end

  def new
    @message = current_user.sent_messages.build
  end
  
  def create
    @message = current_user.sent_messages.build(params[:message])
    
    if @message.save
      flash[:notice] = "Message sent."
      redirect_to :action => "index"
    else
      render :action => "new"
    end
  end
end

It's really just a variant on the standard methods you've probably seen, but it's bound to the current user's sent messages. The "index" method also uses will_paginate so that only 10 messages are listed at once.

But to each controller, we must fill in the views. Use these to start with:

=== file: app/views/layouts/application.html.erb ===

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
  <head>
    <title>Rails Messenger</title>
  </head>
  
  <body>
    <h1>Rails Messenger</h1>
    
    <% if flash[:notice] %>
      <p style="color:green"><%= flash[:notice] %></p>
    <% end %>
    
    <% if flash[:error] %>
      <p style="color:red"><%= flash[:error] %></p>
    <% end %>
    
    <% if logged_in? %>
      <p>Welcome, <%=h current_user.login %>. <%= link_to "Logout", session_path, :method => "delete" %></p>
    <% else %>
      <p>You are not logged in. <%= link_to "Register", new_user_path %> or <%= link_to "Login", new_session_path %></p>
    <% end %>
    
    <%= render :partial => "layouts/mailbox_list" if logged_in? %>
    
    <%= yield %>
  </body>
</html>

=== file: app/views/layouts/_mailbox_list.html.erb ===

<div id="mailbox_list" style="border:1px solid #aaa; float:right; margin:1em; padding:1em; width:20%">
  <p><%= link_to "Compose", new_sent_path %></p>
  
  <p><strong>Mailboxes</strong></p>
  <ul>
    <li><%= link_to "Inbox", inbox_path %></li>
    <li><%= link_to "Sent", :controller => "sent", :action => "index" %></li>
  </ul>
</div>

=== file: app/views/sent/new.html.erb ===

<h2>Compose</h2>

<% form_for :message, :url => {:controller => "sent", :action => "create"} do |f| %>

  <p>
    To:<br />
    <select name="message[to][]">
      <%= options_from_collection_for_select(User.find(:all), :id, :login, @message.to) %>
    </select>
  </p>

  <p>Subject: <%= f.text_field :subject %></p>
  <p>Body:<br /> <%= f.text_area :body %></p>
  <p><%= submit_tag "Send" %></p>
<% end %>

At this point, you can actually send messages if you run script/server. Nobody can read them, but you can indeed send them. They'll get stored in the database correctly.

But we do need a way to view those messages. Let's do that now. We'll start with a way to view sent messages, since we've already done that controller.

=== file: app/views/sent/index.html.erb ===

    <h2>Sent Messages</h2>

    <table border="1">
      <tr>
        <th>To</th>
        <th>Subject</th>
        <th>Sent</th>
      </tr>

      <% for message in @messages %>
        <tr>
          <td><%=h message.recipients.map(&:login).to_sentence %></td>
          <td><%= link_to h(message.subject), sent_path(message) %></td>
          <td><%= distance_of_time_in_words(message.created_at, Time.now) %> ago</td>
        </tr>
      <% end %>
    </table>

    <%= will_paginate @messages %>

=== file: app/views/sent/show.html.erb ===

<h2>Sent: <%=h(@message.subject) %></h2>
<p><strong