Daniel Hintz
Posted on January 14, 2021
Hey Everyone!
I've learned, and applied, a lot in the last few months, but there was a big glaring gap that I just hadn't addressed yet, and that was messaging users outside of my own app. So, to finally get this addressed, I wanted to add functionality to my recipes app to send a shopping list to my users by email and/or text messaging.
Email was pretty obvious, there are classes built into Rails called ActionMailers - these allow you to build email templates similar to how you would build a view in a Rails site, and then sends out that email. Text messaging required a little searching, but ultimately there was a gem that was perfect for my purposes. Here, I'll go through how to set up the emailing functionality in Rails, and then how that will translate into SMS capability using the sms-easy gem.
This is broken down into 4 high-level steps:
- Configuring Rails
- Creating your Mailer
- Setting up your workflow
- Converting the Mailer to use an SMS address
Sending Emails
Like I said above, to send emails from Rails, there is a built in functionality called ActionMailers.
Configure Rails Environment
The first step for using ActionMailers is to get them configured in our app. First, we'll add some configuration to each of our environments. We need to do three things here.
- Tell Rails how we want to deliver our mailer. In this case, using SMTP:
config.action_mailer.delivery_method = :smtp
. -
Set up default URL options to say what URL the email will be sent from and how it will be sent
host = 'localhost:3000' // this needs to be set to your actual site url in your production environment config.action_mailer.default_url_options = { :host => host, protocol: 'http' }
Configure the SMTP settings so Rails can log into the correct service, with the proper authorization, and actually send the email. The below is for Gmail, but there are plenty of other options, such as Outlook, if you don't want to use Gmail just Google "Outlook SMTP Settings":
config.action_mailer.smtp_settings = {
:address => "smtp.gmail.com",
:port => 587,
:user_name => ENV["Email_Username"],
:password => ENV["Email_Password"],
:authentication => "plain",
:enable_starttls_auto => true
}
Setup the Gmail Account
Notice the lines in the above code that use ENV[]
, these point to an environment variable. After all, we don't want to have our email login info out there for the whole world to see. So, the next step is to create these in our .env file (this requires the gem dotenv-rails.
Of course, to get the :user_name
and :password
, we'll need to create a Gmail account. The important thing to keep in mind for this is that, once your account is created, you'll need to go to Settings -> Security -> Signing in to Google
to set up 2-step verification, and then create an App Password to allow your app, a 3rd party app from Google's standpoint, to access your account. This App Password will be the password you include in the smtp_settings above, not your personal password.
Create your Mailer
To create the Mailer, you could manually add the files needed, but Rails comes with a handy-dandy generator to do a lot of the setup for us. Run rails generate mailer shoplist_mailer
and Rails will create a mailer file app/mailers/shoplist_mailer.rb
and a views folder app/views/shoplist_mailer
, along with some testing files.
The Mailer
First, make sure that application_mailer.rb
has the correct defaults set up:
class ApplicationMailer < ActionMailer::Base
default from: "noreply.whats.cookin@gmail.com"
layout 'mailer'
end
Then, within app/mailers/shoplist_mailer.rb
we will add a method to create an email. This method will receive the arguments from your controller as params, and it needs to convert them to instance variables to be passed into a view. It will then use ApplicationMailer's built in #mail
method to pull the view (which we'll set up in the next step) as the template and pass in those instance variables. It should end up looking like this:
class ShoplistMailer < ApplicationMailer
def new_list_email
@recipe_name = params[:recipe]
@user = params[:user]
@shoplist = params[:shoplist]
mail(to: @user.email, subject: "What's Cookin' Shopping List")
end
end
The View
Now that our Mailer is set up to find a view and pass it information, we need to actually create the view. We're going to create 2 files under app/views/shoplist_mailer
, the file names need to match the method name in your Mailer (in this case new_list_email
), and they will be nearly identical except that one will be an html file and the other a text file. What we're doing here is setting up our primary (html) view, and a backup (text) view in case there's an issue with sending html. You shouldn't need to worry about any of the html setup as it should already be done for you in app/views/layouts/mailer.html.erb
.
This is your email template, so make it your own way, but here is mine as an example:
// app/views/shoplist_mailer/new_list_email.html.erb
<h3>Hello <%= @user.name %>,</h3>
<p>
Here is your shopping list for <strong><%= @recipe_name %></strong>:
</p>
<ul><% @shoplist.each do |item| %>
<li><%= item %></li>
<% end %></ul>
<p>Thank you for using <strong>What's Cookin'</strong>. Enjoy your meal!</p>
// app/views/shoplist_mailer/new_list_email.text.erb
Hello <%= @user.name %>,
Here is your shopping list for <%= @recipe_name %>:
<% @shoplist.each do |item| %>
<%= item %>
<% end %>
Thank you for using What's Cookin'. Enjoy your meal!
And that's it, on to the next piece.
Setup Workflow
Okay, now that we have the email settings configured and our mailers created, all that's left to do is get your workflow set up. How does it go from a user clicking a button on the page, to sending that email out?
*Note that your use case may not be exactly the same as mine, so the steps may be a little different. I've tried to note some areas to watch for this, but it's up to you to figure out your app's specific workflow.
- Add a new Route (this step may not be necessary - for example, if you just need an auto-notification upon user signup it would NOT require a new route)
resources :recipes // for the basic Recipe model
post '/recipes/send_shoplist' => 'recipes#send_shoplist' // specific route for sending a shopping list
- Add a new Action to Controller (this step may not be necessary - similar to the route, an auto-notification wouldn't require its own controller action)
// app/controllers/recipes_controller.rb
def send_shoplist
// controller code goes here
end
- Instantiate the Mailer from your Controller and pass in args - this is the step that actually tells Rails to send the email
// create args from params coming from fetch call
@shoplist = params[:shoplist]
@recipe = params[:recipe]
// pass args to Mailer and call delivery method
ShoplistMailer.with(recipe: @recipe, shoplist: @shoplist, user: current_user).new_list_email.deliver_now
render json: "Shopping List Successfully Delivered".to_json
- Connect frontend:
*this entire step may not be necessary - if the user doesn't need to explicitly instruct the app to send them data (ex. auto-notification) there wouldn't necessarily need to be a dedicated call to your API - hence not needing a route and controller action
Here, we need to create a function containing a POST request to the correct Controller action and we'll include whatever data we need for the email in the body (in this case, which recipe and what ingredients were chosen by user). It's also important to keep in mind that the backend will need to know which user to send the email to. In my case, I could identify the current_user
via my authorization flow, but you may need to pass a user_id
as a param instead. Once the function is created, we'll attach it as an eventListener somewhere on our page if it's designed as a manual user action.
// code doesn't include assigning recipe and list variables
function sendShopList() {
fetch(`${RECIPES_URL}/send_shoplist`, {
method: 'POST',
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": // dependent on auth strategy
},
mode: 'cors',
credentials: "include",
body: JSON.stringify({recipe: recipe, shoplist: list})
})
.then(resp => resp.json())
.then(msg => alert(msg))
.catch(function(error) {
alert("Shopping List Could Not Be Delivered - Error Unknown")
console.log(error)
})
}
// Attach function to a button's eventListener
shopListButton.addEventListener("click", () => {sendShopList()})
Sending Text Messages (SMS)
Now that we have our ActionMailers set up, let's add texting functionality as well. We want our users to be able to choose whether the notification is sent to their email, phone, or both. The good news is that adding the texting functionality is almost effortless. Note that setting up fields to gather the needed data is outside the scope of this article, but is very important.
How It Works
First thing's first, let's talk about how this actually works. Like everything else in coding, there are multiple ways to go about texting users. If you are building a commercial app, you may want to look at a service like Twilio to simplify things and make it fool-proof. For our app, we're not concerned about being completely sure that 100% of users have a texting option, so we're going to implement a free method of texting instead.
As a reminder, the gem we're going to use for this is called sms-easy
so make sure that it's installed in your Gemfile. This gem takes advantage of mobile carriers' email-to-text services. Basically, if you send an email to the correct address, it will route to the user as an SMS message instead. So the sms-easy
gem essentially tracks these special addresses and performs a lookup and transformation based on the phone number and carrier. As an aside, it also has functionality to directly send the message from the controller, but we don't want to use that, because we want the text to match the email template that our ActionMailers are already sending out.
So, we need to do 2 things:
- Get the special email-to-text address.
- Route our ActionMailer email to that special address.
Let's look at how it's done.
Implementation
First, we need to update our Mailer from before so that it's flexible enough to change the "to:" address based on passed in input. All we need to do is modify one line:
// FROM
mail(to: @user.email, subject: "What's Cookin' Shopping List")
//TO
mail(to: params[:recipient], subject: "What's Cookin' Shopping List")
Once that's set, we can change the Mailer call in our Controller so that it provides that "to:" value as a parameter:
// FROM
ShoplistMailer.with(recipe: @recipe, shoplist: @shoplist, user: current_user).new_list_email.deliver_now
// TO
ShoplistMailer.with(recipe: @recipe, shoplist: @shoplist, user: current_user, recipient: current_user.email).new_list_email.deliver_now
Now, we need to add a control flow to our Controller based on the user's contact preference so that the proper "to:" parameter gets passed. You can certainly to an if...else statement for this, but this is a great use case for a case statement as well:
case current_user.contactPreference
when 'email'
// Code to send Mailer via email
when 'text'
// Code to send Mailer via text
when 'both'
// Code to send Mailer via email
// Code to send Mailer via text
else
// Can't send option
end
Finally, let's fill in that code. For the email case, we already have the code completed in the previous step, so let's throw it in and add a response message so our app knows everything worked out okay:
when 'email'
ShoplistMailer.with(recipe: @recipe, shoplist: @shoplist, user: current_user, recipient: current_user.email).new_list_email.deliver_now
render json: "Shopping List Successfully Delivered".to_json
For the text case, we need to determine what that special carrier address is and pass that address as the recipient parameter instead of the email. This is where we'll make use of our sms-easy
gem's sms_address
method. The method returns our special address, so let's assign to a variable (recipient
) and then pass that into our Mailer call:
when 'text'
recipient = SMSEasy::Client.sms_address(current_user.phone, current_user.carrier)
ShoplistMailer.with(recipe: @recipe, shoplist: @shoplist, user: current_user, recipient: recipient).new_list_email.deliver_now
render json: "Shopping List Successfully Delivered".to_json
For the both case, we'll assign the recipient variable the same way, and then we'll simply include both of the Mailer calls. The final case, of course, is when there is no match. Normally, this is an error, so we could use begin...raise...rescue
but for my implementation, I preferred to simplify by having my Controller send a message to the front-end and then simply do nothing. All together, the modified #send_shoplist method looks like this:
def send_shoplist
# send selected recipe-ingredients as shoplist
@shoplist = params[:shoplist]
@recipe = params[:recipe]
# send shoplist via chosen contact preference
case current_user.contactPreference
when 'email'
ShoplistMailer.with(recipe: @recipe, shoplist: @shoplist, user: current_user, recipient: current_user.email).new_list_email.deliver_now
render json: "Shopping List Successfully Delivered".to_json
when 'text'
recipient = SMSEasy::Client.sms_address(current_user.phone, current_user.carrier)
ShoplistMailer.with(recipe: @recipe, shoplist: @shoplist, user: current_user, recipient: recipient).new_list_email.deliver_now
render json: "Shopping List Successfully Delivered".to_json
when 'both'
recipient = SMSEasy::Client.sms_address(current_user.phone, current_user.carrier)
ShoplistMailer.with(recipe: @recipe, shoplist: @shoplist, user: current_user, recipient: current_user.email).new_list_email.deliver_now
ShoplistMailer.with(recipe: @recipe, shoplist: @shoplist, user: current_user, recipient: recipient).new_list_email.deliver_now
render json: "Shopping List Successfully Delivered".to_json
else
render json: "Shopping List Could Not Be Sent - No Valid Contact Preference Selected".to_json
end
end
And that's it! Now our backend is set up so that it will email and/or text the user, depending on their contact preference selection.
Posted on January 14, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.