You probably expect from your email client to group related emails into topics. And that’s exactly what we’re going to do today.
TLDR: You can find the code in this GitHub repository. Every part has a corresponding commit.
First, we have to create a new Topic
model with its controller and views.
1
bin/rails g scaffold Topic subject
Each Message
should also be connected to a Topic
, so we have to update existing model using new migration. While we’re at it, we’ll also add a message_id
key to store unique key of each email message. And to keep chain of replies more relevant, we’ll also add a reply_to
reference to another Message
record if newly received email is reply to existing Message
record.
1
bin/rails g migration add_topic_to_messages topic:references reply_to:references message_id:string:index
We have to slightly update the migration to correctly reference reply_to
key to another Message
1
2
3
4
5
6
7
class AddTopicToMessages < ActiveRecord::Migration[7.0]
def change
add_reference :messages, :topic, null: false, foreign_key: true
add_reference :messages, :reply_to, null: true, foreign_key: { to_table: :messages }
add_column :messages, :message_id, :string, index: true
end
end
Now we can update our Message
model. First step would be to reflect new associations in the Code. Message
now belongs to Topic
and another Message
in case it’s a reply. It can also have many replies at the same time.
1
2
3
4
5
6
7
8
class Message < ApplicationRecord
# ...
# ...
belongs_to :topic, touch: true
belongs_to :reply_to, optional: true, class_name: 'Message'
has_many :replies, class_name: 'Message', foreign_key: :reply_to_id, dependent: :nullify
end
Creating Topics
Together with associations, we can automatically create or find relevant Topic
by leveraging before_validation
callback. This way, Topic
record will always exist for new Message
.
But let’s start with tests. We want to ensure that Topic
has same subject as Message
in case we are creating a new one. In case of a reply, we want to automatically assign Topic
of a reply to Message
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
require 'rails_helper'
RSpec.describe Message, type: :model do
it 'builds topic with same subject' do
message = create(:message, subject: 'Test subject')
expect(message.topic.subject).to eq 'Test subject'
end
it 'uses existing topic if the message is a reply' do
message = create(:message)
reply = create(:message, reply_to: message)
expect(reply.topic).to eq message.topic
end
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Message < ApplicationRecord
# ...
# ...
before_validation :build_or_find_topic, if: -> { topic.blank? }
def build_or_find_topic
if reply_to.present?
self.topic = reply_to.topic
else
build_topic(subject: subject)
end
end
end
New association for the Topic
needs to be added too as it now has many Messages
1
2
3
class Topic < ApplicationRecord
has_many :messages, dependent: :destroy
end
Notice the dependent: destroy
part of the association. This ensures removal of all associated Messages
when the Topic
is deleted. Otherwise, we’d be violating associations on a database level causing exceptions.
Tracking Replies
Our Topics are now dependent on tracking replies. Which isn’t implemented yet. To be able to track replies, we have to first save unique ID of each email. Luckily, every email already has unique ID which we can use. Every email which is a reply to another email also references that ID, which we can easily leverage to find existing Message
that new incoming email replies to. Right place for this to happen would be our Inbox code.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# ...
it 'tracks replies' do
mail = Mail.new(
from: 'from@example.com',
to: 'test@example.com',
subject: 'Test email',
body: 'Test body',
)
mail_processed = process(mail)
reply = Mail.new(
from: 'test@example.com',
to: 'from@example.com',
subject: 'Re: Test email',
body: 'Test reply',
references: mail_processed.message_id,
)
process(reply)
expect(Message.count).to eq 2
expect(Message.first.subject).to eq 'Test email'
expect(Message.last.subject).to eq 'Re: Test email'
expect(Message.last.reply_to_id).to eq Message.first.id
end
In this test, we first process
a new email. Then we process
another email which is a reply to the first one - notice the references: mail_processed.message_id
line.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class InboxMailbox < ApplicationMailbox
# ...
def process
@message = Message.create!(
from: mail.from.join(', '),
to: mail.to.join(', '),
subject: mail.subject,
attachments: parsed_attachments.map { |attachment| attachment[:blob] },
content: process_email_content,
message_id: mail.message_id, # new line
reply_to: Message.find_by(message_id: mail.references), # new line
)
end
# ...
As you can see, receiving replies is a really simple two line change. We just save the message_id
and try to find existing Message
with references
(also an ID). If Message
with that ID exists, we know it’s the one this new email is a reply to.
The last piece is to also save message_id
while sending a new email. Which is also simple to do. But let’s start with test!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
describe 'POST /messages' do
# ...
it 'saves message_id' do
message_params = attributes_for(:message)
post :create, params: { message: message_params }
message = Message.last
expect(message.message_id).not_to be_nil
end
# ...
end
Return value of a Mailer is Mail::Message
instance, which we can easily use to access message_id
of the just sent email and save it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# ...
# POST /messages
def create
@message = Message.new(message_params)
if @message.save
email = MessageMailer.with(message: @message).send_message.deliver_now # updated line
@message.update!(message_id: email.message_id) # new line
redirect_to @message, notice: "Message was successfully created."
else
render :new, status: :unprocessable_entity
end
end
# ...
Listing Topics and Replies
Now, that our backend is prepared to group emails into meaningful conversations, we have to update our templates to make sense of our new data structure. But first of all, let’s make Topics listing default page of our little application. Open up routes configuration and uncomment and update existing root entry.
1
2
3
# ...
root 'topics#index'
Default Topics index template requires minimal change. We actually don’t want to have button for a new Topic
there, but for a new Message
as new Topic
is automatically created.
I’ll make this change little harder this time with only one line of a changed code. At this time, you should be more than capable of figuring this out! If not, there’s always GitHub repository to help you.
1
<%= link_to 'New message', new_message_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
The rest of the template is sufficient for us. It simply lists all Topics using app/views/topics/_topic.html.erb
template file. List of topics will serve as a condensed list of all the Topics. Topic detail will then list all its Messages. We just have to add another render
call to the Topic detail template to render all Messages. This render will use app/views/messages/_message.html.erb
template to render every associated Message
of the Topic
.
1
<%= render @topic.messages %>
I’m deliberately including very little code here. Consider these templates your canvas. Add stuff, remove, rearrange, (re)style it… experiment!
To keep this part reasonably long, we add option to send a reply in next part of this series. Stay tuned!
Comments powered by Disqus.