Send emails that thread in Rails
Email threads are great for improving the user experience of your app. In this post we will learn how the RFC 5322 specification expects us to thread emails. We will also learn that emails don’t always work as we expect them to. At the end of this post you will have email threading as another tool in your Rails belt.
- Why do we need email threads?
- Email Headers
- Threading Emails: Our first Approach
- Threading Emails: The Github Approach
- Final thoughts
- Further Reading
TLDR;
Feel free to skip along to Threading Emails: The Github Approach if you are in an email thread emergency.
Why do we need email threads?
Email threads help us keep email conversations coherent. Think about the apps we use on a daily basis. We have conversations on Github that result in email notifications that show in a single thread to help us retain context for each notification. Threaded emails also make it much easier to view all the emails related to a single discussion.
Today we will work on an imaginary application called Great Books. Great Books allow users to follow books and receive notification whenever someone reviews a book. To make it more convenient for our users we would like to make sure that the notification emails thread. Users can then look through a single thread to view all the reviews related to a specific book they follow.
For this post we will focus on the mailer part of this feature.
We can create a NotificationMailer
like this:
# app/mailers/notification_mailer.rb
class NotificationMailer < ApplicationMailer
end
We will get back to Great Books, let us first dig a bit deeper into email headers.
Email Headers
The RFC 5322 - Internet Message Format protocol
defines the syntax required by modern email messages. For this post we are interested in
the 3.6.4.Identification Fields
section. We are specifically interested in the three header fields: Message-ID:
,
In-Reply-To:
and References:
described in that section.
The Message-ID:
field has to be unique as it is used to distinguish one message from
another. The In-Reply-To:
and References:
headers are used when creating a reply to a
message. The message being replied to is also known as the parent message. The latter
headers hold the message identifier of the message being replied to and the message
identifiers of other messages in the thread.
The Message-ID:
header contains a single unique message identifier. The References:
and In-Reply-To:
fields each contain one or more unique message identifiers, optionally
separated by Comments, Folding White Space (CFWS) .
In this post we will use a single space as our separator.
Viewing Email Headers
Before we go any further it might be fun to first take a look at some email headers. In the early days of email, the email headers would have been shown along with the body of the email. Modern email applications hide the headers to make reading the body of the email easier. It is fairly easy to do a quick google search to find out where you can view the email headers for your specific email client. We will take a moment to look for the email headers in Gmail.
We can open Gmail and select any email. Then we click on the vertical three dot menu
button. Next we will select the Show original
option. Gmail will open a new tab with all
the headers right there for us to inspect.
Please take a moment to see if you can find the Message-ID
, In-Reply-To
and
References
fields in the headers from your inbox.
Threading Emails: Our first Approach
Back to Great Books, we would like to send a notification whenever a book is reviewed. The email should thread along with any other review notification sent for the same book in the past.
Step 1: The `message_id` method
The first thing we need to do is to generate a message id. We need to make sure that the id we generate is unique. We have a few options to make sure the generated message id is unique.
- Generate and store a UUID we can easily retrieve whenever we send a new notification:
<cb80c10f-181a-4746-9fa3-57294e9b4edd@our-domain.com>
- Generate a unique id by combining the created at time (in seconds) and perhaps the actual id of a model that is represented in the mail:
<[id]-[time]@our-domain.com> => <123-1634056600@our-domain.com>
I am sure we can come up with many more unique ways to generate a message id. These two examples should help us get a good understanding of what a message id might look like. If we go with option 1 then we would need to store the message id because we won’t be able to recompute the UUID. So to keep it simpler for now, we will go with option 2.
Let us add some more code to Great Books. We will take the simpler approach and compute
the message id on the fly. We can add the following code snippet to our
NotificationMailer
class.
# app/mailers/notification_mailer.rb
private
def message_id(review)
return "" if review.nil?
"<notification-#{review.id}-#{review.created_at.to_i}@#{ENV["domain"]}>"
end
Firstly we return an empty string if the passed in review is nil. Otherwise, notice that
it computes the email’s message id based on some review id
and created_at
time. It
prepens the word notification
and appends the domain name. By adding the prefix and the
suffix we ensure the uniqueness of the message id within the scope of our application.
Interestingly, the Message-ID
is the text between the two angle brackets. The
angle brackets are required, but they are not technically part of the Message-ID
.
It might look a bit cryptic at the moment but follow along and soon the rest of this mailer will come together nicely.
Step 2: The `in_reply_to` and `reviews_in_thread` methods
According to the RFC:
The “In-Reply-To:” field may be used to identify the message (or messages) to which the new message is a reply. It contains the contents of the “Message-ID:” of the “parent message”. If the reply is part of a thread with multiple parent messages, then the “In-Reply-To:” field will contain the contents of all of the parents’ “Message-ID:” fields. The “In-Reply-To:” header can be omitted if no parent message in the thread has a “Message-ID:”.
When we read the RFC it looks like the In-Reply-To
field should at least refer to its
direct parent message. We don‘t really have emails going back and forth between the
application and our users. Instead, emails are only sent out from Great Books to users.
This simplifies things because we can use the Message-ID
of the last notification to
make it seem like a reply email.
The trickiest part might be to find the Message-ID
of the previously sent notification.
So we will add two methods. One to retrieve the previous reviews and another to generate
the In-Reply-To
field. Remember that we only care about the reviews for a specific book,
as all the reviews for that book will belong to a single email thread.
First we add the below method that retrieves the other reviews that belong to the thread.
# app/mailers/notification_mailer.rb
def reviews_in_thread(review)
Review.where.not(id: review.id)
.where(book_id: review.book_id)
.order(:created_at)
end
We will have to assume that each Great Books review belongs to a book so a review will have
a book_id
field. We retrieve all the previous reviews in the thread, not the current
review we are notifying users about, and we order them by their created_at
time. If
there are no other reviews it will return an empty relation. If we do have other reviews
the last review in the query result will be the most recent review.
Now we need to generate content for the In-Reply-To
header field.
# app/mailers/notification_mailer.rb
def in_reply_to(review)
previous_review = reviews_in_thread(review).last
message_id(previous_review)
end
We take only the last review as it will be associated with the last notification we sent
for the currently reviewed book. The last notification is therfore the parent email of
the notification we are currently sending out. We will soon combine the entire set of
headers, but this concludes the In-Reply-To:
field section.
Step 3: The `references` method
The last header we need to take care of is the References
header. The RFC defines the
References:
field as follows:
“References:” field may be used to identify a “thread” of conversation.
…
The “References:” field will contain the contents of the parent's “References:” (if any) followed by the contents of the parent's “Message-ID:” (if any). If the parent message does not contain a “References:” field but does have an “In-Reply-To:” field containing a single message identifier, then the “References:” field will contain the contents of the parent's “In-Reply-To:” field followed by the contents of the parent's “Message-ID:” field (if any). If the parent has none of the “References:”, “In-Reply-To:”, or “Message-ID:” fields, then the new message will have no “References:” field.
It helps to sometimes draw a scenario out. So let us consider what this header will look like with each additional notification.
The first notification will not have a References:
field because there won’t be a parent
message. So we will send a notification where the email headers include only the
Message-ID:
. Let us imaging an email with this:
Message-ID: <notification-1-1634556591@example.com>
The next notification in the thread should have a References:
field that contains the
Message-ID
of the previous message in this thread.
And finally, a third notification will contain the references of the second message, its parent message, followed by the message id of the second message.
The RFC documentation mentions that some implementations can make use of the
References:
field to display the “thread of the discussion”. From the examples above it
is becoming clearer that it is possible to walk backwards through the “References:” field
to find the parent of each message listed.
Adding a method that gives us the contents for the References:
header is fairly easy. We
already have a method that gives us all the other reviews in the thread. Remember that the
reviews_in_thread
method also sorts the reviews from earliest to latest.
# app/mailers/notification_mailer.rb
def references(review)
reviews_in_thread(review).map { |x| message_id(x) }.join(" ")
end
Notice that we separate the message ids with spaces.
Step 4: The `headers` method
Now we have a method for each of the three headers that we are interested in. To bring
them all together we will add a very appropriately named method called headers
.
def headers(review)
{
in_reply_to: in_reply_to(review),
message_id: message_id(review),
references: references(review)
}.reject { |_, v| v.blank? }
end
This method can be added to the private section of our NotificationMailer class. Notice
that it accepts and passes along the current review. It also removes any nil
or empty
headers with the use of the built-in Ruby reject method.
We are very close and the final thing left to do is to add the method that gets called when we want to send a notification.
Step 5: The `review_notification` method
Each ActionMailer class should contain at least one method that will send an email. You
can read more about ActionMailer in the ruby on rails guides .
We are going to add a new method and call it review_notification
. This method will accept
a recipient and a review. With a recipient and a review we will be able to send an email
with the headers required for threading.
def review_notification(recipient, review)
@review = review
@recipient = recipient
mail_settings = {
to: "#{recipient.name} <#{recipient.email}>",
subject: "Review: #{review.book.title}"
}.merge(headers(review))
mail(mail_settings)
end
We create the instance variables to pass data along to our mailer views. Then we set the recipient name and email followed by the email subject line. The subject line is not important right now, but we will talk a bit more about it later on. Finally we merge the headers we already created.
Step 6: Send emails
To send an email we need to call the mailer .
We can make use of deliver_now
or deliver_later
but the purpose and implementation of
async queues are out of scope for this post. So we will make use of deliver_now
We typically want to send emails from a controller or services object and often with the use of some queuing service. Sending an email will look something like this:
NotificationMailer.review_notification(recipient, review).deliver_now
Gmail is not threading
When we send emails using this mailer we notice that Apple Mail app threads perfectly. But the celebration is only short lived because emails are not threaded in Gmail.
From the official google feed/blog
we learn that if you receive two emails with the same subject from the same sender, these
emails will not be threaded together unless one explicitly references the other (using the
References:
header).
It seems like we are adhering to this rule, so where do we go from here?
Threading Emails: The Github Approach
Going back to the drawing board we might decide to see how other service providers manage to send emails that thread correctly. One such a company is Github. If you have any notification in your inbox from Github then you will notice that they have done a very good job of threading emails.
If you look at the headers for a few messages in a Github thread, you will notice that The very first email in a thread has no In-Reply-To: or References: header fields. It contains only the Message-ID:.
Message-ID: <orgname/project/pull/id@github.com>
The next email in the thread will have In-Reply-To:
and References:
fields that refer
to the first Message-ID:
. And all following emails in the thread will have the exact
same In-Reply-To:
and References:
fields:
This implementation is clearly different from how the RFC instructs us to set our headers for threading to work. But if this approach is good enough for Github then surely it is good enough for Great Books.
Now it is refactor time.
Step 1: Add `thread_id`
To make sure we are all on the same page, let us examine the headers from the Github
emails. From the second mail onwards all emails in the thread have the same In-Reply-To:
and References:
fields. Both of these header fields refer to the same single
Message-ID
. There is no header called the Thread-Id
but we will call this message id
the thread id for ease of reference.
The thread id refers to the first review notification so we will use the first review in a thread when we construct the thread id.
# app/mailers/notification_mailer.rb
def thread_id(review)
first_review = reviews_in_thread(review).first
message_id(first_review)
end
Our thread id is a message id that refers to the first review in a thread. No matter which
review in the thread we look at, the thread id will always be the same. Except for the
very first review. The first review notification will not have a In-Reply-To:
or
References:
field
Remember that reviews_in_thread
will return an empty relation if there are no other
reviews in a thread. In that case first_review
will be nil
and we already made sure
that message_id
returns an empty string when a nil
review is passed in.
Step 2: Update `in_reply_to` and `references`
# app/mailers/notification_mailer.rb
def in_reply_to(review)
thread_id(review)
end
def references(review)
thread_id(review)
end
Both methods now return the same value. Which in most people’s books is a code smell. When we look at the headers method we realise that these two methods are “man in the middle” methods. So we can actually update the headers method like so:
def headers(review)
{
in_reply_to: thead_id(review),
message_id: message_id(review),
references: thead_id(review)
}.reject { |_, v| v.blank? }
end
We no longer use the in_reply_to
or references
method so we can remove them 🎉.
Step 3: Do we need the `Message-ID`?
You might find that your email service provider overwrites or ignores the message_id
that you provide. If that is the case for you then there is no point in providing the
message_id
, as it will be overwritten. Does that mean we have wasted our time up to this
point?
The good news is that not all is lost! We don’t need to pass the message id for threading
to work. As long as our In-Reply-To:
and References:
fields are consistent our emails
will thread.
We can now simplify our NotificationMailer class. We can remove the message_id
header.
And now the headers
method becomes.
def headers(review)
{
in_reply_to: thead_id(review),
references: thead_id(review)
}.reject { |_, v| v.blank? }
end
To illustrate what our emails will look like now, let us consider the following
thread_id
.
thread_id = "<notification-3-1634824271@example.com>"
The first email in a thread will not have References:
or In-Reply-To:
fields. But from
the second email onwards the References:
and In-Reply-To:
fields will both use the
thread_id
as their value.
We have a very simple mailer class and we have emails that thread, what a great day. I am sure we can optimise this class even further but I will leave it as it is for now.
Does the subject line matter?
The subject line does matter. Sending emails with consistent In-Reply-To:
and
References:
fields will not thread correctly if we don’t use the same subject line.
Documentation on the internet makes it seem like all we need is the email headers for
threading to work, but trial and error has proved differently.
So Great Books will use the same subject line for all emails that belong in the same thread.
Final thoughts
We took a detour but landed on a very elegant looking mailer class. We learned that we cannot always follow the specifications that an RFC provides. The RFC was a good starting point, but we could have saved ourselves a bit of effort if we learned from those (like Github) that came before us.
Email threading is a great tool for improving the user experience of our applications.
Contact OmbuLabs to hear how our expertise can help you skip right to the elegant solutions. We save our clients time and deliver products that provide great user experiences.