Skip to content

Commit

Permalink
Add Mailer.deliver_now! and deliver_later!
Browse files Browse the repository at this point in the history
What changed?
=============

We update `deliver_now` and `deliver_later` to not raise on errors. They
now return an `{:ok, email}` (or `{:ok, email, response}` with
`response: true`) or `{:error, error}`.

We introduce `deliver_now!` and `deliver_later!` to replace what used to
be `deliver_now` and `deliver_later` -- in other words, they raise
because that used to be the implementation.

Hopefully, that makes for an easy upgrade path. Those who wish to keep
the current behavior can switch to `deliver_now!` and `deliver_later!`
and keep moving forward.

Those who want to handle errors can receive ok/error tuples.

Note on `deliver_later` and `!`
--------------------------------

`deliver_later` now returns an `{:ok, email_to_send}` or `{:error,
error}` if there is an error in the email validations _before_ we
schedule the email sending. But if an error occurs during the delayed
task, we raise an error.

`deliver_later!` will raise an error if we have email validation
failures before we schedule the email. This is the existing behavior of
`deliver_later`, so it's an easy upgrade for those uninterested in the
new change: `deliver_later` -> `deliver_later!`.

Note on empty recipients (not nil)
-------------------------------

While working on this update, I noticed we did not raise an error when
`to, cc, bcc` are all empty lists (after formatting). We decide to keep
in line with this behavior, so we do not return an error in those cases
either.

We do not want to raise on scenarios the recipient list is empty because
that means the user of bamboo interacted with those lists. If all of
those fields are `nil`, we do return or raise an error because it means
the user of bamboo might not have set anything accidentally (`nil` is
the default value for all of those). That has always been the case, and
will continue to be.
  • Loading branch information
germsvel committed Feb 19, 2021
1 parent 90fdca9 commit ae247fc
Show file tree
Hide file tree
Showing 8 changed files with 301 additions and 96 deletions.
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ email in fitting places within your application.
defmodule MyApp.SomeControllerPerhaps do
def send_welcome_email do
Email.welcome_email() # Create your email
|> Mailer.deliver_now() # Send your email
|> Mailer.deliver_now!() # Send your email
end
end
```
Expand Down Expand Up @@ -200,9 +200,10 @@ process completion (e.g. a web request in Phoenix). Bamboo provides a
also provides a [`Bamboo.DeliverLaterStrategy`] behaviour that you can
implement to tailor your background email sending.

By default, `deliver_later`uses [`Bamboo.TaskSupervisorStrategy`]. This strategy
sends the email right away, but it does so in the background without linking to
the calling process, so errors in the mailer won't bring down your app.
By default, `deliver_later` uses [`Bamboo.TaskSupervisorStrategy`]. This
strategy sends the email right away, but it does so in the background without
linking to the calling process, so errors in the mailer won't bring down your
app.

You can also create custom strategies by implementing the
[`Bamboo.DeliverLaterStrategy`] behaviour. For example, you could create
Expand Down
12 changes: 5 additions & 7 deletions lib/bamboo.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,13 @@ defmodule Bamboo do
use Application

defmodule EmptyFromAddressError do
defexception [:message]
defexception message: ~S"""
The from address was empty. Set an address as a string, a 2 item tuple
{name, address}, or something that implements the Bamboo.Formatter protocol.
"""

def exception(_) do
%EmptyFromAddressError{
message: """
The from address was empty. Set an address as a string, a 2 item tuple
{name, address}, or something that implements the Bamboo.Formatter protocol.
"""
}
%EmptyFromAddressError{}
end
end

Expand Down
4 changes: 3 additions & 1 deletion lib/bamboo/adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ defmodule Bamboo.Adapter do
end
"""

@callback deliver(%Bamboo.Email{}, %{}) :: any
@type error :: Exception.t() | String.t()

@callback deliver(%Bamboo.Email{}, %{}) :: {:ok, any} | {:error, error}
@callback handle_config(map) :: map
@callback supports_attachments? :: boolean
end
2 changes: 1 addition & 1 deletion lib/bamboo/adapters/local_adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ defmodule Bamboo.LocalAdapter do
end

def deliver(email, _config) do
SentEmail.push(email)
{:ok, SentEmail.push(email)}
end

def handle_config(config), do: config
Expand Down
1 change: 1 addition & 0 deletions lib/bamboo/adapters/test_adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ defmodule Bamboo.TestAdapter do
def deliver(email, _config) do
email = clean_assigns(email)
send(test_process(), {:delivered_email, email})
{:ok, email}
end

defp test_process do
Expand Down
174 changes: 127 additions & 47 deletions lib/bamboo/mailer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ defmodule Bamboo.Mailer do
@moduledoc """
Functions for delivering emails using adapters and delivery strategies.
Adds `deliver_now/1` and `deliver_later/1` functions to the mailer module it
is used by.
Adds `deliver_now/1`, `deliver_now!/1`, `deliver_later/1` and
`deliver_later!/1` functions to the mailer module in which it is used.
Bamboo [ships with several adapters][available-adapters]. It is also possible
to create your own adapter.
Expand Down Expand Up @@ -49,7 +49,7 @@ defmodule Bamboo.Mailer do
end
end
You are now able to send emails with your mailer module where you sit fit
You are now able to send emails with your mailer module where you see fit
within your application.
"""

Expand All @@ -63,19 +63,35 @@ defmodule Bamboo.Mailer do

defmacro __using__(opts) do
quote bind_quoted: [opts: opts] do
@spec deliver_now(Bamboo.Email.t(), Enum.t()) :: Bamboo.Email.t() | {Bamboo.Email.t(), any}
@spec deliver_now(Bamboo.Email.t(), Enum.t()) ::
{:ok, Bamboo.Email.t()}
| {:ok, Bamboo.Email.t(), any}
| {:error, Exception.t() | String.t()}
def deliver_now(email, opts \\ []) do
{config, opts} = Keyword.split(opts, [:config])
config = build_config(config)
Bamboo.Mailer.deliver_now(config.adapter, email, config, opts)
end

@spec deliver_now!(Bamboo.Email.t(), Enum.t()) :: Bamboo.Email.t() | {Bamboo.Email.t(), any}
def deliver_now!(email, opts \\ []) do
{config, opts} = Keyword.split(opts, [:config])
config = build_config(config)
Bamboo.Mailer.deliver_now!(config.adapter, email, config, opts)
end

@spec deliver_later(Bamboo.Email.t()) :: Bamboo.Email.t()
def deliver_later(email, opts \\ []) do
config = build_config(opts)
Bamboo.Mailer.deliver_later(config.adapter, email, config)
end

@spec deliver_later!(Bamboo.Email.t()) :: Bamboo.Email.t()
def deliver_later!(email, opts \\ []) do
config = build_config(opts)
Bamboo.Mailer.deliver_later!(config.adapter, email, config)
end

otp_app = Keyword.fetch!(opts, :otp_app)

defp build_config(config: dynamic_config_overrides) do
Expand Down Expand Up @@ -108,11 +124,17 @@ defmodule Bamboo.Mailer do
`deliver_later/1` if you want to send in the background.
Pass in an argument of `response: true` if you need access to the response
from delivering the email. This returns a tuple of the `Email` struct and the
response from calling `deliver` with your adapter. This is useful if you need
access to any data sent back from your email provider in the response.
from delivering the email.
Email.welcome_email |> Mailer.deliver_now(response: true)
A successful email delivery returns an ok tuple with the `Email` struct and
the response (if `response: true`) from calling `deliver` with your adapter.
A failure returns `{:error, error}` tuple.
Having the response returned from your adapter is useful if you need access to
any data sent back from your email provider in the response.
{:ok, email, response} = Email.welcome_email |> Mailer.deliver_now(response: true)
Pass in an argument of `config: %{}` if you would like to dynamically override
any keys in your application's default Mailer configuration.
Expand All @@ -124,6 +146,20 @@ defmodule Bamboo.Mailer do
raise @cannot_call_directly_error
end

@doc """
Deliver an email right away.
Same as `deliver_now/2` but does not return an ok/error tuple.
If successful, this function returns the `Email` struct or an `Email`,
response tuple when setting `response: true`.
On failure, this function returns an error tuple: `{:error, error}`.
"""
def deliver_now!(_email, _opts \\ []) do
raise @cannot_call_directly_error
end

@doc """
Deliver an email in the background.
Expand All @@ -132,51 +168,91 @@ defmodule Bamboo.Mailer do
`Bamboo.TaskSupervisorStrategy` will be used. See
`Bamboo.DeliverLaterStrategy` to learn how to change how emails are delivered
with `deliver_later/1`.
If the email is successfully scheduled for delivery, this function will return
an `{:ok, email}`.
If the email is invalid, this function will return an `{:error, error}` tuple.
"""
def deliver_later(_email, _opts \\ []) do
raise @cannot_call_directly_error
end

@doc false
def deliver_now(adapter, email, config, response: true) do
email = email |> validate_and_normalize(adapter)
@doc """
Deliver an email in the background.
if email.to == [] && email.cc == [] && email.bcc == [] do
debug_unsent(email)
email
else
debug_sent(email, adapter)
response = adapter.deliver(email, config)
{email, response}
end
Same as `deliver_later!/2` but does not return an ok tuple and raises on
errors.
If successful, this function only returns the `Email` struct.
If the email is invalid, this function raises an error.
"""
def deliver_later!(_email, _opts \\ []) do
raise @cannot_call_directly_error
end

@doc false
def deliver_now(adapter, email, config, _opts) do
email = email |> validate_and_normalize(adapter)
def deliver_now(adapter, email, config, opts) do
with {:ok, email} <- validate_and_normalize(email, adapter) do
if empty_recipients?(email) do
debug_unsent(email)

{:ok, email}
else
debug_sent(email, adapter)

case adapter.deliver(email, config) do
{:ok, response} -> format_response(email, response, opts)
{:error, _} = error -> error
end
end
end
end

if email.to == [] && email.cc == [] && email.bcc == [] do
debug_unsent(email)
defp format_response(email, response, opts) do
put_response = Keyword.get(opts, :response, false)

if put_response do
{:ok, email, response}
else
debug_sent(email, adapter)
adapter.deliver(email, config)
{:ok, email}
end
end

email
@doc false
def deliver_now!(adapter, email, config, opts) do
case deliver_now(adapter, email, config, opts) do
{:ok, email, response} -> {email, response}
{:ok, email} -> email
{:error, error} -> raise error
end
end

@doc false
def deliver_later(adapter, email, config) do
email = email |> validate_and_normalize(adapter)
with {:ok, email} <- validate_and_normalize(email, adapter) do
if empty_recipients?(email) do
debug_unsent(email)
else
debug_sent(email, adapter)
config.deliver_later_strategy.deliver_later(adapter, email, config)
end

if email.to == [] && email.cc == [] && email.bcc == [] do
debug_unsent(email)
else
debug_sent(email, adapter)
config.deliver_later_strategy.deliver_later(adapter, email, config)
{:ok, email}
end
end

@doc false
def deliver_later!(adapter, email, config) do
case deliver_later(adapter, email, config) do
{:ok, email} -> email
{:error, error} -> raise error
end
end

email
defp empty_recipients?(email) do
email.to == [] && email.cc == [] && email.bcc == []
end

defp debug_sent(email, adapter) do
Expand All @@ -200,45 +276,49 @@ defmodule Bamboo.Mailer do
end

defp validate_and_normalize(email, adapter) do
email |> validate(adapter) |> normalize_addresses
case validate(email, adapter) do
:ok -> {:ok, normalize_addresses(email)}
error -> error
end
end

defp validate(email, adapter) do
email
|> validate_from_address
|> validate_recipients
|> validate_attachment_support(adapter)
with :ok <- validate_from_address(email),
:ok <- validate_recipients(email),
:ok <- validate_attachment_support(email, adapter) do
:ok
end
end

defp validate_attachment_support(%{attachments: []} = email, _adapter), do: email
defp validate_attachment_support(%{attachments: []} = _email, _adapter), do: :ok

defp validate_attachment_support(email, adapter) do
defp validate_attachment_support(_email, adapter) do
if Code.ensure_loaded?(adapter) && function_exported?(adapter, :supports_attachments?, 0) &&
adapter.supports_attachments? do
email
:ok
else
raise "the #{adapter} does not support attachments yet."
{:error, "the #{adapter} does not support attachments yet."}
end
end

defp validate_from_address(%{from: nil}) do
raise Bamboo.EmptyFromAddressError, nil
{:error, %Bamboo.EmptyFromAddressError{}}
end

defp validate_from_address(%{from: {_, nil}}) do
raise Bamboo.EmptyFromAddressError, nil
{:error, %Bamboo.EmptyFromAddressError{}}
end

defp validate_from_address(email), do: email
defp validate_from_address(_email), do: :ok

defp validate_recipients(%Bamboo.Email{} = email) do
if Enum.all?(
Enum.map([:to, :cc, :bcc], &Map.get(email, &1)),
&is_nil_recipient?/1
) do
raise Bamboo.NilRecipientsError, email
{:error, Bamboo.NilRecipientsError.exception(email)}
else
email
:ok
end
end

Expand Down
2 changes: 1 addition & 1 deletion test/lib/bamboo/adapters/local_adapter_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ defmodule Bamboo.LocalAdapterTest do
test "sent emails has emails that were delivered synchronously" do
email = new_email(subject: "This is my email")

email |> LocalAdapter.deliver(@config)
{:ok, _response} = email |> LocalAdapter.deliver(@config)

assert [%Bamboo.Email{subject: "This is my email"}] = SentEmail.all()
end
Expand Down
Loading

0 comments on commit ae247fc

Please sign in to comment.