By NovaWave Solutions, aka manitoba98
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.
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):
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:
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.
end
Finally, we'll move on to our User model. We'll define associations for both sent and received messages, as well as folders.
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.
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
folders.find_by_name("Inbox")
end
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.
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.
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 === Rails Messenger Rails Messenger Welcome, . You are not logged in. or === file: app/views/layouts/_mailbox_list.html.erb === Mailboxes === file: app/views/sent/new.html.erb === Compose To: Subject: Body:
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 ===
Sent Messages
To
Subject
Sent
ago
=== file: app/views/sent/show.html.erb ===
Sent:
To:
Sent:
You can now send messages and view the messages you've sent. Not bad, eh? But we're still missing the most essential functionality: reading received messages. Let's get on that. Open the MailboxController. We'll code that next.
=== file: app/controllers/mailbox_controller.rb ===
end
=== file: app/vies/mailbox/show.html.erb ===
From
Subject
Received
ago
Okay, so that controller uses a little trick that makes our code a bit more DRY, but it begs explanation. When the user views his/her inbox, the "index" action is called. It sets @folder to the current user's inbox, then calls and renders the show action. The show action will see if @folder is already defined. If it is, it will use that (in this case, the inbox). If not, it will load one from the ID parameter. It will then paginate through the messages in that folder.
We now can list messages in our inbox, but we can't read them. For that, we'll need to dive into the MessagesController.
=== file: app/controllers/messages_controller.rb === end === file: app/views/messages/show.html.erb === From: To: Received:
It's alive! You can now send and receive messages: the basic functionality works. We can now move on to "bonus" features, like Reply, more folders, multiple recipients, etc.
Reply is probably one of the more popular messaging features. We will now implement it in our application. First, we'll alter our routes file to create a "reply" action. We will then write that action, and modify our compose view to work with it.
=== file: config/routes.rb === ActionController::Routing::Routes.draw do |map| map.resources :users, :sent, :mailbox map.resources :messages, :member => {:reply => :get } 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 === file: app/controllers/messages_controller.rb === end === file: app/views/messages/show.html.erb === From: To: Received:
There's some regular expression magic there for adding the classic "Re: " tag (but not if it's already there) and adding the similarly-popular indentation style. Try it out, it works. If you want forwarding, it should be fairly logical how to accomplish that. Just omit the bit that specifies the new message's recipients in your forward action, and use "Fwd:" instead of "Re:". Forwarding functionality will be in the final copy at the end of this tutorial, but I won't describe it fully here. Consider it an exercise for the reader.
Another common feature is the ability to send to multiple recipients. This system does actually already support that: only the form doesn't. The simplest way to add that is adding the "multiple" parameter to our <select> tag. But personally, I'm not a big fan of that widget. I'll show you how to implement a scrolling checklist instead.
Okay, so let's go back to our compose view (app/views/sent/new.html.erb). We'll take out that ugly select tag and replace it to a call to "checklist", a helper method we'll define. It takes parameters similar to collection_select.
=== file: app/views/sent/new.html.erb ===
Compose
To:
Subject:
Body:
=== file: app/helpers/application_helper.rb ===
# Methods added to this helper will be available to all templates in the application.
end
That's now working, go ahead and check it out. Reply (and forward, if you did that) are still working. And it already works because our application actually supported multiple recipients in the backend from the beginning. We just needed to provide a user interface to that. And we have.
Ah yes, the dreaded Reply All feature. It can be annoying, but it's often considered a mainstay of the email experience, which we're effectively replicating. Reply All is rather similar to Reply, so I'll just show you the code. For those of you who didn't code forwarding yourself earlier, I've also done that here.
=== file: config/routes.rb ===
ActionController::Routing::Routes.draw do |map|
map.resources :users, :sent, :mailbox
map.resources :messages, :member => {:reply => :get, :forward => :get, :reply_all => :get }
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
=== file: app/controllers/messages_controller.rb ===
end
=== file: app/views/messages/show.html.erb ===
From:
To:
Received:
|
|
Rather than implementing a full, real delete (like you could by calling #destroy), we'll make our deletion functionality just hide it. For this, we'll create a "deleted" column in our message_copies table. We'll show deleted messages in the Trash folder, and non-deleted messages in the inbox (and later, other folders). You will even be able to restore them if you want. To do this, we'll use a plugin called "scope_out" which we installed earlier. We'll call scope_out in our MessageCopy model and create the needed column in the message_copies table. Don't forget to migrate the database again (I haven't shown that here, but it's still necessary). After that comes the more laborious (somewhat) task of replacing the relevant calls so that they return only not deleted messages. Finally, we'll add an interface for deleting, undeleting, and viewing deleted messages.
=== file: db/005_add_deleted_column.rb ===
add_column :message_copies, :deleted, :boolean
end
remove_column :message_copies, :deleted
end
end
=== file: app/models/message_copy.rb ===
end
=== file: app/controllers/messages_controller.rb ===
end
=== file: app/controllers/mailbox_controller.rb ===
end
=== file: app/views/layouts/_mailbox_list.html.erb ===
Mailboxes
=== file: app/views/messages/show.html.erb ==
From:
To:
Received:
|
|
|
=== file: config/routes.rb ===
ActionController::Routing::Routes.draw do |map|
map.resources :users, :sent
map.resources :mailbox, :collection => {:trash => :get }
map.resources :messages, :member => {:reply => :get, :forward => :get, :reply_all => :get, :undelete => :put }
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
Okay, that may look long, but if you actually read it, most of it is code we wrote earlier. Only little segments here and there had to be changed: I included the entire file for completeness.
More will come, just wait. I thought I'd post this here, even though I haven't done everything I've got planned. (Preview: Folder organization and RSS support is coming soon). I'll post a zip file of the whole thing when it's done.
Update: My apologies for not updating this in some time (nearly a year, I'm ashamed to say). Life got in the way, then I put if off a few times, and before I know it, it was almost 2009. If there's anyone reading this, know that it's a New Year's Resolution of mine to update this. In the meantime, I did take the time to go and export a ZIP file of the project as it exists now.