Home Simple e-mail service with Rails - Part 5: Grouping to Topics
Post
Cancel

Simple e-mail service with Rails - Part 5: Grouping to Topics

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!

This post is licensed under CC BY 4.0 by the author.
Trending Tags
Contents

Simple e-mail service with Rails - Part 4: Receiving e-mails

Simple e-mail service with Rails - Part 6: Sending Replies

Comments powered by Disqus.

Trending Tags