Mix.install([
{:jason, "~> 1.4"},
{:kino, "~> 0.9", override: true},
{:youtube, github: "brooklinjazz/youtube"},
{:hidden_cell, github: "brooklinjazz/hidden_cell"}
])
Upon completing this lesson, a student should be able to answer the following questions.
- How do we use Swoosh and SendGrid to send emails?
- How do we configure environment variables?
- How do we use Oban to schedule jobs?
SendGrid is an email provider that allows businesses to send email communications to customers and prospects. It provides a platform for email delivery, which includes a web-based interface for managing contacts and creating and sending email campaigns, as well as APIs for integrating email functionality into applications.
Swoosh is an Elixir library for sending emails.The library allows you to send emails using various email providers such as Sendgrid, SMTP, Mailgun and more.
Oban is an Elixir library for running background jobs. It is a powerful and flexible job queue built on top of OTP. Oban allows you to enqueue jobs and process them in the background, which can be useful for tasks that are time-consuming or need to be run independently of the application's main process. The jobs can be executed concurrently and can be scheduled to run at a specific time.
By default, Phoenix defines a App.Mailer
(where App
is the app name) module in app/mailer.ex
, which uses the Swoosh.Mailer
module.
defmodule App.Mailer do
use Swoosh.Mailer, otp_app: :app
end
The Mailer
module is configured with some adapter for sending emails with Swoosh and some email provider such as SendGrid.
This is the final feature we're going to add to the PicChat
application we've spent the last few lessons building. This lesson will focus on sending and scheduling emails with Oban and Swoosh.
We're going to demonstrate how to send an email in the IEx shell. First, start the server.
iex -S mix phx.server
Run the following in the IEx shell to build a Swoosh.Email struct, then pass that struct to the Mailer.deliver/2 function to send the email.
import Swoosh.Email
new()
|> to("[email protected]")
|> from({"sender", "sender@sender_domain.com"})
|> subject("Test Email Subject")
|> html_body("<h1>Test Email</h1>")
|> text_body("Test Email")
|> PicChat.Mailer.deliver()
Visit http://localhost:4000/dev/mailbox to see the sent email.
Users should always be able to opt-in and out of notifications. We're going to add a subscribed
field to every user and only send emails to subscribed users.
Run the following to create the migration.
$ mix ecto.gen.migration add_subscribed_to_users
Add the following to the migration.
defmodule PicChat.Repo.Migrations.AddSubscribedToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add :subscribed, :boolean, default: false
end
end
end
Add the following to the User
schema.
# User.ex Inside The `schema` Macro.
field :subscribed, :boolean, default: false
Modify the registration_changeset
to add the :subscribed
field.
# User.ex
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :password, :username, :subscribed])
|> validate_email(opts)
|> validate_password(opts)
|> validate_username()
end
Add a checkbox when registering a user so that they can opt-in to email notifications.
# User_registration_live Inside Of The Existing Form.
<.input field={@form[:subscribed]} type="checkbox" label="Receive email updates" />
We're going to send all of our users a daily email containing a summary of the messages sent that day.
Create the following function to find the list of all messages created today. fragment/1 lets us inject SQL directly into a query. Here we use it to convert the inserted_at
value to a date to compare it with todays date.
# Chat.ex
def todays_messages do
today = Date.utc_today()
from(m in Message,
where: fragment("date(inserted_at) = ?", ^today),
order_by: [desc: :inserted_at, desc: :id]
)
|> Repo.all()
end
This implementation could result sending too many messages in the email, or missing messages depending on when we send the daily email. A more complex and reliable implementation could be to have a flag on every message to store whether or not they were sent in the previous daily summary email.
Add the following function to get a list of subscribed user emails from the database.
# Accounts.ex
def subscriber_emails() do
from(u in User, where: u.subscribed == true, select: u.email)
|> Repo.all()
end
Create a lib/pic_chat/summary_emails.ex
context that sends every subscriber an email with a summary of all of todays messages.
defmodule PicChat.SummaryEmail do
import Swoosh.Email
@sender_name "PicChat"
@sender_email "[email protected]"
def send_to_subscribers do
messages = PicChat.Chat.todays_messages()
subscribers = PicChat.Accounts.subscriber_emails()
for subscriber <- subscribers do
PicChat.Mailer.deliver(build(subscriber, messages))
end
end
def build(receiver_email, messages) do
new()
|> to(receiver_email)
|> from({@sender_name, @sender_email})
|> subject("PicChat Summary Report")
|> html_body("""
<h1>Summary Report</h1>
#{Enum.map(messages, &render_message/1)}
""")
|> text_body("""
Summary Report
#{messages |> Enum.map(&(&1.content)) |> Enum.join("\n")}
""")
end
defp render_message(message) do
"""
<p>#{message.content}</p>
"""
end
end
Follow the Oban Installation Instructions to add Oban to your project.
We'll outline the steps here.
First, add oban to your list of dependencies in mix.exs
. Make sure your version is up to date.
{:oban, "~> 2.14"}
Configure Oban in config.exs
.
config :pic_chat, Oban,
repo: Newsletter.Repo,
plugins: [Oban.Plugins.Pruner],
queues: [default: 10]
Add Oban testing configuration in test.exs
.
# Oban
config :pic_chat, Oban, testing: :inline
Add Oban to your application's supervision tree.
def start(_type, _args) do
children = [
# Start the Ecto repository
PicChat.Repo,
# Start the Telemetry supervisor
PicChatWeb.Telemetry,
# Start the PubSub system
{Phoenix.PubSub, name: PicChat.PubSub},
# Start the Endpoint (http/https)
PicChatWeb.Endpoint,
# Start a worker by calling: Newsletter.Worker.start_link(arg)
# {Newsletter.Worker, arg},
{Oban, Application.fetch_env!(:pic_chat, Oban)} # Added Oban
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: PicChat.Supervisor]
Supervisor.start_link(children, opts)
end
Oban can schedule workers to perform a job in a certain amount of time, or at a specific time. See Oban: Scheduling Jobs for more information.
Create a lib/pic_chat/workers/daily_summary_email.ex
worker with the following content.
defmodule PicChat.Workers.DailySummaryEmail do
# We've made max_attempts: 1 to avoid re-sending users the same email.
use Oban.Worker, queue: :default, max_attempts: 1
@impl true
def perform(_params) do
PicChat.SummaryEmail.send_to_subscribers()
:ok
end
end
Run the following in the IEx shell to schedule the job after five seconds.
iex> PicChat.Workers.DailySummaryEmail.new(%{}, schedule_in: 5) |> Oban.insert!()
Oban allows us to configure Cron based scheduling.
Modify the oban config in config.exs
to add a daily CRON job that runs at 8am every day.
config :pic_chat, Oban,
repo: PicChat.Repo,
plugins: [
Oban.Plugins.Pruner,
{Oban.Plugins.Cron,
crontab: [
{"0 8 * * *", PicChat.Workers.DailySummaryEmail}
]}
],
queues: [default: 10]
Testing time can be tricky. However, it's possible to override the inserted_at
field in a record. The following test demonstrates how to ensure that the Chat.todays_messages/0
function only finds messages created today.
# Chat_test.exs
test "todays_messages/0" do
user = user_fixture()
today_message = message_fixture(user_id: user.id)
yesterday =
NaiveDateTime.add(NaiveDateTime.utc_now(), -1, :day) |> NaiveDateTime.truncate(:second)
yesterday_message =
PicChat.Repo.insert!(%Message{
content: "some content",
user_id: user.id,
inserted_at: yesterday
})
assert Chat.todays_messages() == [today_message]
end
We can test the perform/1
function directly if desired.
For example, create a test file pic_chat/workers/daily_summary_email_test.exs
with the following content.
defmodule PicChat.Workers.DailySummaryEmailTest do
use PicChat.DataCase
use Oban.Testing, repo: PicChat.Repo
alias PicChat.Workers.DailySummaryEmail
alias PicChat.SummaryEmail
import Swoosh.TestAssertions
import PicChat.AccountsFixtures
import PicChat.ChatFixtures
test "perform/1 sends daily summary emails" do
user = user_fixture(email: "[email protected]", subscribed: true)
message1 = message_fixture(user_id: user.id)
message2 = message_fixture(user_id: user.id)
assert :ok = DailySummaryEmail.perform(%{})
assert_email_sent SummaryEmail.build(user.email, [message2, message1])
end
end
Oban also provides the Oban.Testing.assert_enqueued/2 function for testing if a job is enqueued.
Here's a fantastic video that you may watch if you'd like to learn more about testing Oban.
YouTube.new("https://www.youtube.com/watch?v=PZ48omi0NKU&ab_channel=ElixirConf")
So far, we've only sent emails to the development mailbox. We have not actually configured our email system to send real emails.
Now, we're going to configure the SendGrid
to send actual emails when in a production environment.
To use SendGrid with Swoosh, we'll need a SendGrid API Token.
To get a SendGrid API Token, complete the following steps.
- Sign up for a Free SendGrid Account.
- Create a Single Sender. This will be the configuration used for sending emails and receiving replies.
- Verify your sender identity through the confirmation email.
- Set up MFA (Multi-Factor Authentication)
- Create an API key. You can create a Full Access key if you would like, but it's safer to create a Restricted Access key with the "Mail Send" permission. Make sure to save your API key someplace safe where others will not be able to view it.
Upon completing the above, review your sender to ensure they have been successfully set up.
Unfortunately SendGrid can take some time to verify the account and you may not be allowed to send emails right away. If this is the case, your teacher can provide you with a temporary key to use.
Create a .env
file with the following content. Replace KEY with your API key.
export SENDGRID_API_KEY="KEY"
Add the .env
file to .gitignore
to prevent putting API keys in GitHub.
# .gitignore
.env
Make sure to source the .env
file into the environment.
$ source .env
Make the sender name and sender email match the sender you created on SendGrid.
# Summary_email.ex
@sender_name "YOUR_SENDER_NAME"
@sender_email "YOUR_SENDER_EMAIL"
Replace the existing mailer config with the following in config.exs
to setup SendGrid.
# Config :pic_chat, PicChat.Mailer, Adapter: Swoosh.Adapters.Local
config :pic_chat, PicChat.Mailer,
adapter: Swoosh.Adapters.Sendgrid,
api_key: System.get_env("SENDGRID_API_KEY")
By default, prod.exs
should already be configured to use Finch for sending HTTP requests.
# Configures Swoosh API Client
config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: PicChat.Finch
That's everything we need to send emails in the production environment.
Generally, it's unwise to send real emails while in the dev environment as it's possible to accidentally send users development emails. That's why dev.exs
configures :swoosh
so that it can't send any emails.
config :swoosh, :api_client, false
However, we're going to briefly configure the application to send real emails for testing purposes. Replace the dev.exs
config that overwrites the :swoosh
adapter with the following.
# Config :swoosh, :api_client, False
config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: PicChat.Finch
Run the following in the IEx shell to send a real email. Replace YOUR_EMAIL
, SENDER_NAME
, and SENDER_EMAIL
with your own information from SendGrid and your own email.
import Swoosh.Email
new()
|> to("YOUR_EMAIL")
|> from({"SENDER_NAME", "SENDER_EMAIL"})
|> subject("Test Email Subject")
|> html_body("<h1>Test Email</h1>")
|> text_body("Test Email")
|> PicChat.Mailer.deliver()
Check your email or SendGrid to verify the email was sent. It may be in the Spam folder. You can view sent emails on the SendGrid activity feed.
Once verified, make sure you change the development configuration back.
# Dev.exs
config :swoosh, :api_client, false
# Config :swoosh, Api_client: Swoosh.ApiClient.Finch, Finch_name: PicChat.Finch
Consider the following resource(s) to deepen your understanding of the topic.
DockYard Academy now recommends you use the latest Release rather than forking or cloning our repository.
Run git status
to ensure there are no undesirable changes.
Then run the following in your command line from the curriculum
folder to commit your progress.
$ git add .
$ git commit -m "finish PicChat: Emails reading"
$ git push
We're proud to offer our open-source curriculum free of charge for anyone to learn from at their own pace.
We also offer a paid course where you can learn from an instructor alongside a cohort of your peers. We will accept applications for the June-August 2023 cohort soon.