lib/swoosh/mailer.ex

defmodule Swoosh.Mailer do
  @moduledoc ~S"""
  Defines a mailer.

  A mailer is a wrapper around an adapter that makes it easy for you to swap the
  adapter without having to change your code.

  It is also responsible for doing some sanity checks before handing down the
  email to the adapter.

  When used, the mailer expects `:otp_app` as an option.
  The `:otp_app` should point to an OTP application that has the mailer
  configuration. For example, the mailer:

      defmodule Sample.Mailer do
        use Swoosh.Mailer, otp_app: :sample
      end

  Could be configured with:

      config :sample, Sample.Mailer,
        adapter: Swoosh.Adapters.Sendgrid,
        api_key: "SG.x.x"

  Most of the configuration that goes into the config is specific to the adapter,
  so check the adapter's documentation for more information.

  Per module configuration is also supported, it has priority over mix configs:

      defmodule Sample.Mailer do
        use Swoosh.Mailer, otp_app: :sample,
          adapter: Swoosh.Adapters.Sendgrid,
          api_key: "SG.x.x"
      end

  System environment variables can be specified with `{:system, "ENV_VAR_NAME"}`:

      config :sample, Sample.Mailer,
        adapter: Swoosh.Adapters.SMTP,
        relay: "smtp.sendgrid.net"
        username: {:system, "SMTP_USERNAME"},
        password: {:system, "SMTP_PASSWORD"},
        tls: :always

  ## Examples

  Once configured you can use your mailer like this:

      # in an IEx console
      iex> email = new |> from("tony.stark@example.com") |> to("steve.rogers@example.com")
      %Swoosh.Email{from: {"", "tony.stark@example.com"}, ...}
      iex> Mailer.deliver(email)
      :ok

  You can also pass an extra config argument to `deliver/2` that will be merged
  with your Mailer's config:

      # in an IEx console
      iex> email = new |> from("tony.stark@example.com") |> to("steve.rogers@example.com")
      %Swoosh.Email{from: {"", "tony.stark@example.com"}, ...}
      iex> Mailer.deliver(email, domain: "jarvis.com")
      :ok

  ## Telemetry

  Each mailer outputs the following telemetry events:

  - `[:swoosh, :deliver, :start]`: occurs when `Mailer.deliver/2` begins.
  - `[:swoosh, :deliver, :stop]`: occurs when `Mailer.deliver/2` completes.
  - `[:swoosh, :deliver, :exception]`: occurs when `Mailer.deliver/2` throws an exception.
  - `[:swoosh, :deliver_many, :start]`: occurs when `Mailer.deliver_many/2` begins.
  - `[:swoosh, :deliver_many, :stop]`: occurs when `Mailer.deliver_many/2` completes.
  - `[:swoosh, :deliver_many, :exception]`: occurs when `Mailer.deliver_many/2` throws an exception.

  ### Capturing events

  You can capture events by calling `:telemetry.attach/4` or `:telemetry.attach_many/4`. Here's an example:

      # tracks the number of emails sent successfully/errored
      defmodule MyHandler do
        def handle_event([:swoosh, :deliver, :stop], _measurements, metadata, _config) do
          StatsD.increment("mail.sent.success", 1, %{mailer: metadata.mailer})
        end

        def handle_event([:swoosh, :deliver, :exception], _measurements, metadata, _config) do
          StatsD.increment("mail.sent.failure", 1, %{mailer: metadata.mailer})
        end

        def handle_event([:swoosh, :deliver_many, :stop], _measurements, metadata, _config) do
          StatsD.increment("mail.sent.success", length(metadata.emails), %{mailer: metadata.mailer})
        end

        def handle_event([:swoosh, :deliver_many, :exception], _measurements, metadata, _config) do
          StatsD.increment("mail.sent.failure", length(metadata.emails), %{mailer: metadata.mailer})
        end
      end

  in `c:Application.start/2` callback:

      :telemetry.attach_many("my-handler", [
         [:swoosh, :deliver, :stop],
         [:swoosh, :deliver, :exception],
         [:swoosh, :deliver_many, :stop],
         [:swoosh, :deliver_many, :exception],
       ], &MyHandler.handle_event/4, nil)
  """

  alias Swoosh.DeliveryError

  defmacro __using__(opts) do
    quote bind_quoted: [opts: opts] do
      alias Swoosh.Mailer

      @otp_app Keyword.fetch!(opts, :otp_app)
      @mailer_config opts

      @spec deliver(Swoosh.Email.t(), Keyword.t()) :: {:ok, term} | {:error, term}
      def deliver(email, config \\ [])

      def deliver(email, config) do
        metadata = %{email: email, config: config}

        instrument(:deliver, metadata, fn ->
          Mailer.deliver(email, parse_config(config))
        end)
      end

      @spec deliver!(Swoosh.Email.t(), Keyword.t()) :: term | no_return
      def deliver!(email, config \\ [])

      def deliver!(email, config) do
        case deliver(email, config) do
          {:ok, result} -> result
          {:error, reason} -> raise DeliveryError, reason: reason
        end
      end

      @spec deliver_many(list(%Swoosh.Email{}), Keyword.t()) :: {:ok, term} | {:error, term}
      def deliver_many(emails, config \\ [])

      def deliver_many(emails, config) do
        metadata = %{emails: emails, config: config}

        instrument(:deliver_many, metadata, fn ->
          Mailer.deliver_many(emails, parse_config(config))
        end)
      end

      @on_load :validate_dependency

      @doc false
      def validate_dependency do
        adapter = Keyword.get(parse_config([]), :adapter)
        Mailer.validate_dependency(adapter)
      end

      defp parse_config(config) do
        Mailer.parse_config(@otp_app, __MODULE__, @mailer_config, config)
      end

      defp instrument(key, metadata, fun) do
        metadata = Map.merge(metadata, %{mailer: __MODULE__})

        :telemetry.span([:swoosh, key], metadata, fn ->
          case fun.() do
            {:ok, result} -> {{:ok, result}, Map.put(metadata, :result, result)}
            {:error, error} -> {{:error, error}, Map.put(metadata, :error, error)}
          end
        end)
      end
    end
  end

  def deliver(%Swoosh.Email{from: nil}, _config) do
    {:error, :from_not_set}
  end

  def deliver(%Swoosh.Email{from: {_name, address}}, _config)
      when address in ["", nil] do
    {:error, :from_not_set}
  end

  def deliver(%Swoosh.Email{} = email, config) do
    adapter = Keyword.fetch!(config, :adapter)

    :ok = adapter.validate_config(config)
    adapter.deliver(email, config)
  end

  def deliver_many(emails, config) do
    adapter = Keyword.fetch!(config, :adapter)

    :ok = adapter.validate_config(config)
    adapter.deliver_many(emails, config)
  end

  @doc """
  Parse configs in the following order, later ones taking priority:

  1. mix configs
  2. compiled configs in Mailer module
  3. dynamic configs passed into the function
  4. system envs
  """
  def parse_config(otp_app, mailer, mailer_config, dynamic_config) do
    Application.get_env(otp_app, mailer, [])
    |> Keyword.merge(mailer_config)
    |> Keyword.merge(dynamic_config)
    |> Swoosh.Mailer.interpolate_env_vars()
  end

  @doc """
  Interpolate system environment variables in the configuration.

  This function will transform all the {:system, "ENV_VAR"} tuples into their
  respective values grabbed from the process environment.
  """
  def interpolate_env_vars(config) do
    Enum.map(config, fn
      {key, {:system, env_var}} -> {key, System.get_env(env_var)}
      {key, value} -> {key, value}
    end)
  end

  @doc false
  def validate_dependency(adapter) do
    require Logger

    with adapter when not is_nil(adapter) <- adapter,
         {:module, _} <- Code.ensure_loaded(adapter),
         true <- function_exported?(adapter, :validate_dependency, 0),
         :ok <- adapter.validate_dependency() do
      :ok
    else
      no_match when no_match in [nil, false] ->
        :ok

      {:error, :nofile} ->
        Logger.error("#{adapter} does not exist")
        :abort

      {:error, deps} when is_list(deps) ->
        Logger.error(Swoosh.Mailer.missing_deps_message(adapter, deps))
        :abort
    end
  end

  @doc false
  def missing_deps_message(adapter, deps) do
    deps =
      deps
      |> Enum.map(fn
        {lib, module} -> "#{module} from #{inspect(lib)}"
        module -> inspect(module)
      end)
      |> Enum.map(&"\n- #{&1}")

    """
    The following dependencies are required to use #{inspect(adapter)}:
    #{deps}
    """
  end
end