VBT.Mailer behaviour (vbt v0.1.0) View Source

Helper for simpler e-mail sending using Bamboo and Phoenix templates.

This module simplifies the implementation of a typical e-mail sending logic, by conflating mailer, composer, database-backed queue, and templating concerns into a single module.

Example

In a typical scenario, it is advised to use VBT.Mailer together with Oban based persistent queue. The queue improves delivery guarantees, reducing the chance of e-mail not being sent if something goes wrong (for example, if the mail server is not reachable).

To do this, you need to first create a new migration which initializes Oban tables:

defmodule MyProject.Migrations.InitializeOban do
  use Ecto.Migration

  def up, do: Oban.Migrations.up()
  def down, do: Oban.Migrations.down()
end

Then you need to configure the email queue in config.exs

# `email: 10` defines the queue called "emails" with the maximum concurrency of 10
config :my_project, Oban, repo: MyRepo, queues: [email: 10]

Make sure to disable queues in config/test.exs:

config :my_project, Oban, crontab: false, queues: false, plugins: false

Next, you need to start the oban process tree in your application:

defmodule MyProject.Application do
  # ...

  def start(_type, _args) do
    children = [
      # ...
      MyRepo,
      # make sure to start oban after the repo
      {Oban, Application.fetch_env!(:my_project, Oban)},
      # ...
    ]

    # ...
  end
end

Finally, you can define the mailer module in your context:

defmodule MyMailer do
  use VBT.Mailer,
    oban_worker: [queue: "email"],
    templates: "templates",
    adapter: Bamboo.SendGridAdapter

  @spec send_password_reset(String.t(), String.t()) ::
    {:ok, Oban.Job.t} | {:error, Ecto.Changeset.t}
  def send_password_reset(email, password_reset_link) do
    VBT.Mailer.enqueue(
      __MODULE__,
      "sender@x.y.z",
      "recipient@x.y.z",
      "Reset your password",
      %{
        layout: :some_layout,
        template: :some_template,
        password_reset_link: password_reset_link
      }
    )
  end

  @impl VBT.Mailer
  def config(), do: %{api_key: System.fetch_env!("SENDGRID_API_KEY")}
end

Mailer is a wrapper around Bamboo, so it can use any conforming adapter. The adapter will only be used in :prod. Mailer always uses Bamboo.LocalAdapter in :dev, and Bamboo.TestAdapter in :test.

If you prefer to test the real adapter in development mode, you can pass the dev_adapter: Bamboo.SendGridAdapter option (or any other real adapter you're using). However, it's advised to instead test the real mailer by running the :prod-compiled version locally.

Transactions

If you need to send an e-mail inside a transaction, you can invoke enqueue/5 from within Ecto.Multi.run/3:

Ecto.Multi.run(multi, :enqueue_mail, fn _repo, _arg -> VBT.Mailer.enqueue(...) end)

In this case, the e-mail will be enqueued once the transaction is committed. If the transaction is rolled back, the e-mail will not be enqueued.

Queue options

The :oban_worker options are passed to Oban.Worker. By default, the :max_attempts value and the auto generated backoff strategy will cause the queue to retry for at most 24 hours.

Body templates

The template files (.eex) should be placed into the templates folder, relative to the mailer file.

The code above expects both .text and .html files to exist. If you want to use the text format only, you need to provide template as a string, and have it end with the .text suffix:

%{layout: :some_layout, template: "some_template.text"}

Note that layout is always provided as atom, without the extension. Bamboo will correctly use some_layout.text.eex to render the layout.

All other data in the map is forwarded as assigns to the template. In the example above, a template can reference @password_reset_link

Using without templates

It is also possible to use mailer without Phoenix templates. In this case, you don't need to provide the :templates option in use. When sending an e-mail, you can provide plain string for the body argument. This string is used as the text body. If you wish to provide both text and the html body, you can use %{text: text_body, html: html_body}.

Immediate sending

If you want to skip the queue, you can use send!/5, which will send the mail synchronously. In this case, you don't need to provide the :oban_worker option when using this module.

Testing

If e-mails are sent through the queue, you need to manually drain the queue before checking for the delivery. For this purpose, a helper drain_queue function is injected into your mailer module, but only in the :test environment:

  test "sends reset password email" do
    MyMailer.send_password_reset(email, reset_links)

    MyMailer.drain_queue()

    mail = assert_delivered_email(to: [{_, ^email}])
    assert mail.text_body =~ reset_link
  end

Dynamic repos

If you need to run multiple instances of mailer, pass the :name option to Oban instance. Then you need to pass the same name as an option to enqueue/6 and drain_queue/0.

Link to this section Summary

Functions

Composes the email and sends it to the target address.

Link to this section Types

Specs

address() :: String.t() | {String.t(), String.t()}

Specs

address_list() :: nil | address() | [address()] | any()

Specs

body() ::
  String.t()
  | %{text: String.t(), html: String.t()}
  | %{
      :layout => atom(),
      :template => atom() | String.t(),
      optional(atom()) => any()
    }

Specs

opts() :: [
  attachments: [Bamboo.Attachment.t()],
  cc: [address_list()],
  bcc: [address_list()],
  headers: %{required(String.t()) => String.t()},
  name: GenServer.server()
]

Link to this section Functions

Link to this function

enqueue(mailer, from, to, subject, body, opts \\ [])

View Source

Specs

enqueue(module(), address(), address(), String.t(), body(), opts :: opts()) ::
  {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}

Enqueues the mail for sending.

Link to this function

send!(mailer, from, to, subject, body, opts \\ [])

View Source

Specs

send!(module(), address_list(), address_list(), String.t(), body(), opts()) ::
  :ok

Composes the email and sends it to the target address.

Link to this section Callbacks

Specs

config() :: map()