In the last part, we’ve learned how to send an e-mail. Even if with formatted text, we’re still missing attachments. Rails ActionText editor can handle attachments, but it’s a bit tricky. We would need to manually attach each file to the e-mail, so receiver can actually see the file. For now, let’s use this restriction as an opportunity to learn how to upload files using Rails. And of course send them as e-mail attachments.
TLDR: You can find the code in this GitHub repository. Every part has a corresponding commit.
We’ve already initialized ActiveStorage
in our project. Adding ability to accept attachments to the Message model is just one line of code.
1
2
3
4
5
6
7
class Message < ApplicationRecord
has_rich_text :content
has_many_attached :attachments # NEW LINE
validates :from, presence: true
validates :to, presence: true
end
We also have to add new form input to the Message form so it can accept file attachments. You can add it whenever you please, I’ve chosen to add it as a last input before the submit button.
1
2
3
4
<div class="my-5">
<%= form.label :attachments %>
<%= form.file_field :attachments, multiple: true %>
</div>
And we also have to allow this new attachments
parameter to be accepted by our controller action. Just add new key to message_params
method.
1
2
3
def message_params
params.require(:message).permit(..., attachments: [])
end
If you did everything right you should be able to create a new Message now and select some files using new attachments file input. However, attachments are visible at the moment. Let’s fix that and add attachments to the Message template. Yet again, you can place this attachments rendering code whenever you like. I chose to add it after the content.
1
2
3
4
5
6
7
8
9
10
11
12
13
...
<p class="my-5">
<% message.attachments.each do |attachment| %>
<%= link_to rails_blob_url(attachment.blob), download: true, class: "bg-gray-100 inline-flex py-1 px-1 rounded-lg items-center hover:bg-gray-200 border" do %>
<span class="leading-none">
<span class="title-font font-medium text-xs"><%= attachment.blob.filename %></span>
</span>
<% end %>
<% end %>
</p>
...
This code loops through every saved attachment of the Message. For every attachment, it prints download link using rails_blob_url
with the file name. Every file in ActiveStorage is represented by its ActiveStorage::Blob
. It contains key to the storage and name of the storage used. This way ActiveStorage can find the correct stored file. It also includes name of the file and some other metadata. You can open up Rails console using bin/rails console
command and see for yourself. Using this metadata, rails_blob_url
helper knows exactly for which file and where to look to create a URL for the file.
1
2
3
4
5
6
7
8
9
10
11
12
# fetching blob of the first attachment of last saved Message
> Message.last.attachments.first.blob
=> #<ActiveStorage::Blob:0x000000014fee10b0
id: 1,
key: "9aj7hybrihy65unrgw2zmxqejc6v",
filename: "sample.pdf",
content_type: "application/pdf",
metadata: {"identified"=>true, "analyzed"=>true},
service_name: "local",
byte_size: 3028,
checksum: "S0GjR1EyvYYbMKh44wqlag==",
created_at: Fri, 08 Dec 2023 21:08:34.889331000 UTC +00:00>
Saving attachments and displaying them is nice and all, but what we really want is to include attachments in the e-mail. We will also be loping through every Message attachment, but instead of printing it, we need to attach it to the e-mail.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MessageMailer < ApplicationMailer
def send_message
@message = params[:message]
# new code
@message.attachments.each do |attachment|
attachments[attachment.blob.filename.to_s] = {
mime_type: attachment.blob.content_type,
content: attachment.blob.download,
}
end
mail subject: @message.subject, from: @message.from, to: @message.to, cc: @message.cc, bcc: @message.bcc
end
end
Similarly to printing attachments in the template, we loop through every attachment and attach it to the e-mail. For that, we yer again need a file name. But instead of printing a link, we have to fully read the file contents and include it in the e-mail, so it’s recognized as an attachment.
It’s also a good idea to update MessageMailer
tests and include a case to check if attachments are correctly added. As an attachment for testing, I’ve added a sample PDF file to spec/fixtures
directory. For setup, the test creates a Message record and attaches the file to it. The rest of the test then checks whether there’s precisely one attachment included and its name.
1
2
3
4
5
6
7
8
9
10
11
it 'includes attachments' do
file_path = Rails.root.join('spec/fixtures/sample.pdf')
file = fixture_file_upload(file_path, 'application/pdf')
# simulate manual attachment
message.attachments.attach(file)
message.save
expect(mail.attachments.count).to eq 1
expect(mail.attachments.first.filename).to eq 'sample.pdf'
end
Go ahead and use the testing button to send the Message again. Or create a new one. LetterOpener preview of the e-mail should now include files you’ve uploaded as attachments.
Rich content editor inline attachments
As I’ve mentioned in the begining, our rich content editor can also upload attachments. We’ve come far with simple upload process, it’s time to get fancy with inline attachments.
Luckily enough, getting fancy is as simple as figuring out how to access attachments saved for Message
model rich_text
content. And also how to manually create such a content, so we can write a test for that.
Contrary to previous approach, let’s write a test first. The process is similar to manual attachments approach. Instead of attaching the file directly to Message, we first create a Blob for our test file. This Blob can then be saved as part of rich_content
embed_blobs
. Then, we yet again check for number of mail attachments and name of the file, so we are sure we’re attaching the file we want.
1
2
3
4
5
6
7
8
9
10
11
12
13
it 'includes inline attachments' do
file_path = Rails.root.join('spec/fixtures/sample.pdf')
file = fixture_file_upload(file_path, 'application/pdf')
# simulate inline attachment
blob = ActiveStorage::Blob.create_and_upload!(filename: 'sample.pdf', io: file)
message.content.embeds_blobs = [blob]
message.save
expect(mail.attachments.count).to eq 1
expect(mail.attachments.first.filename).to eq 'sample.pdf'
end
Go ahead, run the test and see it fail. You should see a failure very similar to the one below. First expectation failed because there’re no attachments in the e-mail yet.
1
2
3
4
5
6
7
8
9
10
Failures:
1) MessageMailer Sending a message includes inline attachments
Failure/Error: expect(mail.attachments.count).to eq 1
expected: 1
got: 0
(compared using ==)
# ./spec/mailers/message_spec.rb:53:in `block (3 levels) in <top (required)>'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MessageMailer < ApplicationMailer
def send_message
@message = params[:message]
# regular uploaded attachments
@message.attachments.each do |attachment|
attachments[attachment.blob.filename.to_s] = {
mime_type: attachment.blob.content_type,
content: attachment.blob.download,
}
end
# new code
# rich content inline attachments
@message.content.embeds.each do |attachment|
attachments.inline[attachment.blob.filename.to_s] = {
mime_type: attachment.blob.content_type,
content: attachment.blob.download,
}
end
mail subject: @message.subject, from: @message.from, to: @message.to, cc: @message.cc, bcc: @message.bcc
end
end
And voilà, the test now passes. You can now try to create a new Message and drop some files in the Rich text editor. Those files are now also part of e-mail attachments.
ActiveStorage Direct uploads
As per ActiveStorage documentation
Active Storage, with its included JavaScript library, supports uploading directly from the client to the cloud.
First of all, we have to include ActiveStorage JavaScript library. Using Impormaps, you can pin ActiveStorage running this command in the console.
1
./bin/importmap pin @rails/activestorage
Then, as per documentation, we have to include activestorage.js
in our application’s JavaScript bundle. It’s done by adding these two lines into application.js
.
1
2
import * as ActiveStorage from "@rails/activestorage"
ActiveStorage.start()
Last requirement is to change the file input field so it uses direct uploads by adding direct_upload: true
.
1
2
3
4
<div class="my-5">
<%= form.label :attachments %>
<%= form.file_field :attachments, multiple: true, direct_upload: true %>
</div>
Direct uploads now work. It’s easy as that. But we can still get a little bit fancier results! In the Direct upload example, there’re short JavaScript and CSS files we can add to our application to easily display progress of the upload process. Create two new files app/javascript/direct_uploads.js
and app/assets/stylesheets/direct_uploads.css
and paste code from the example page to respective files. New stylesheet file should be included automatically, we just have to include new JavaScript code.
1
2
// add as a last line of the file
import "./direct_uploads.js"
If you create a new Message with manual attachments, you should see progress bar displayed for each file after submitting the form. We haven’t added anything new, but our users now know that the page is waiting for the uploads. Instead of just hanging without any visual information.
Comments powered by Disqus.