Skip to content

Latest commit

 

History

History
645 lines (446 loc) · 20.1 KB

picchat_emails.livemd

File metadata and controls

645 lines (446 loc) · 20.1 KB

PicChat: Emails

Mix.install([
  {:jason, "~> 1.4"},
  {:kino, "~> 0.9", override: true},
  {:youtube, github: "brooklinjazz/youtube"},
  {:hidden_cell, github: "brooklinjazz/hidden_cell"}
])

Navigation

Review Questions

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?

Overview

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.

PicChat: Emails

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.

Example Email

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.

Subscribe Users

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.

Create Migration

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

Modify Schema

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 Subscribed Checkbox

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" />

Daily Summary Email

We're going to send all of our users a daily email containing a summary of the messages sent that day.

Find Todays Messages

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.

Find All Subscribed User Emails

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

Build And Send Summary Emails

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

Configure Oban

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

DailySummaryEmail Worker

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!()

Daily Cron Job

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]

Tests

Todays Messages

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

Testing Oban

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")

Production Emails

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.

Create SendGrid Account

To use SendGrid with Swoosh, we'll need a SendGrid API Token.

To get a SendGrid API Token, complete the following steps.

  1. Sign up for a Free SendGrid Account.
  2. Create a Single Sender. This will be the configuration used for sending emails and receiving replies.
  3. Verify your sender identity through the confirmation email.
  4. Set up MFA (Multi-Factor Authentication)
  5. 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.

Set The API Key In The Environment

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

Alter @sender_name And @sender_email.

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"

SendGrid Config

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")

Prod Adapter Config

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.

Send Email In Development

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

Further Reading

Consider the following resource(s) to deepen your understanding of the topic.

Commit Your Progress

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.

Navigation