lib/swoosh/gallery.ex

defmodule Swoosh.Gallery do
  @moduledoc ~S"""
  Swoosh.Gallery is a library to preview and display your Swoosh mailers to everyone,
  either by exposing the previews on your application's router or publishing it on
  your favorite static host solution.

  ## Getting Started

  You will a gallery module to organize all your previews, and implement the expected
  callbacks on your mailer modules:

      defmodule MyApp.Mailer.Gallery do
        use Swoosh.Gallery

        preview("/welcome", MyApp.Mailer.WelcomeMailer)
      end

      defmodule MyApp.Mailer.WelcomeMailer do
        # the expected Swoosh / Phoenix.Swoosh code that you already have to deliver emails
        use Phoenix.Swoosh, view: SampleWeb.WelcomeMailerView, layout: {MyApp.LayoutView, :email}

        def welcome(user) do
          new()
          |> from("noreply@sample.test")
          |> to({user.name, user.email})
          |> subject("Welcome to Sample App")
          |> render_body("welcome.html", user: user)
        end

        # `preview/0` function that builds your email using fixture data
        def preview do
          welcome(%{email: "user@sample.test", name: "Test User!"})
        end

        # `preview_details/0` with some useful metadata about your mailer
        def preview_details do
          [
            title: "Welcome to MyApp!",
            description: "First email to welcome users into the platform"
          ]
        end
      end

  Then in your router, you can mount your Gallery to expose it to the web:

      forward "/gallery", MyApp.Mailer.Gallery

  Or, you can generate static web pages with all the previews from your gallery:

      mix swoosh.gallery.html --gallery MyApp.Mailer.Gallery --path "_build/emails"

  By default, the previews will be sorted alphabetically. You can customize the sorting
  by implementing the `sort/1` callback on your gallery module:

      defmodule MyApp.Mailer.Gallery do
        use Swoosh.Gallery,
          sort: &MyApp.Mailer.Gallery.sort/1

        preview("/welcome", MyApp.Mailer.WelcomeMailer)
      end

   Or disable sorting by passing `sort: false`, so the previews will be displayed in the
   defined order in your Gallery.

       defmodule MyApp.Mailer.Gallery do
        use Swoosh.Gallery, sort: false
        ...
      end


  ### Generating preview data

  Previews should be built using in memory fixture data and we do **not recommend** that you
  reuse your application's code to query for existing data or generate files during runtime. The
  `preview/0` can be invoked multiple times as you navigate through your gallery on your browser
  when mounting it on the router or when using the `swoosh.gallery.html` task to generate the static
  pages.

      defmodule MyApp.Mailer.SendContractEmail do
        def send_contract(user, blob) do
          contract =
            Swoosh.Attachment.new({:data, blob}, filename: "contract.pdf", content_type: "application/pdf")

          new()
          |> to({user.name, user.email})
          |> subject("Here is your Contract")
          |> attachment(contract)
          |> render_body(:contract, user: user)
        end

        # Bad - invokes application code to query data and generate the PDF contents
        def preview do
          user = MyApp.Users.find_user("testuser@acme.com")
          {:ok, blob} = MyApp.Contracts.build_contract(user)
          build(user, blob)
        end

        # Good - uses in memory structs and existing fixtures
        def preview do
          blob = File.read!("#{Application.app_dir(:my_app, "my_app")}/fixtures/sample.pdf")
          build(%User{}, blob)
        end
      end
  """

  @doc """
  Sorts the previews. It receives a list of previews and should return a list of previews.
  """
  @callback sort(list(%{preview_details: map()})) :: list()
  @optional_callbacks sort: 1

  @spec __using__(any) ::
          {:__block__, [], [{:@ | :def | :import | {any, any, any}, [...], [...]}, ...]}
  defmacro __using__(options) do
    quote do
      @behaviour Swoosh.Gallery

      import unquote(__MODULE__)
      Module.register_attribute(__MODULE__, :previews, accumulate: true)
      Module.register_attribute(__MODULE__, :groups, accumulate: true)
      @group_path nil
      @sort Keyword.get(unquote(options), :sort, true)

      def init(opts) do
        Keyword.put(opts, :gallery, __MODULE__.get())
      end

      def call(conn, opts) do
        Swoosh.Gallery.Plug.call(conn, opts)
      end

      @before_compile unquote(__MODULE__)
    end
  end

  defmacro __before_compile__(_env) do
    quote do
      @doc false
      def get do
        previews = eval_details(@previews)
        %{previews: previews, groups: @groups, sort: @sort}
      end
    end
  end

  @doc ~S"""
  Declares a preview route. If expects that the module passed implements both
  `preview/0` and `preview_details/0`.

  ## Examples

      defmodule MyApp.Mailer.Gallery do
        use Swoosh.Gallery

        preview "/welcome", MyApp.Emails.Welcome
        preview "/account-confirmed", MyApp.Emails.AccountConfirmed
        preview "/password-reset", MyApp.Emails.PasswordReset

      end
  """
  @spec preview(String.t(), module()) :: no_return()
  defmacro preview(path, module) do
    path = validate_path(path)
    module = Macro.expand(module, __ENV__)
    validate_preview_details!(module)

    quote do
      @previews %{
        group: @group_path,
        path: build_preview_path(@group_path, unquote(path)),
        email_mfa: {unquote(module), :preview, []},
        details_mfa: {unquote(module), :preview_details, []}
      }
    end
  end

  @doc """
  Defines a scope in which previews can be nested when rendered on your gallery.
  Each group needs a path and a `:title` option.

  ## Example

      defmodule MyApp.Mailer.Gallery do
        use Swoosh.Gallery

        group "/onboarding", title: "Onboarding Emails" do
          preview "/welcome", MyApp.Emails.Welcome
          preview "/account-confirmed", MyApp.Emails.AccountConfirmed
        end

        preview "/password-reset", MyApp.Emails.PasswordReset
      end

  ## Options

  The supported options are:

    * `:title` - a string containing the group name.
  """
  defmacro group(path, opts, do: block) do
    path = validate_path(path)

    group =
      opts
      |> Keyword.put(:path, path)
      |> Keyword.validate!([:path, :title])
      |> Map.new()
      |> Macro.escape()

    quote do
      is_nil(@group_path) || raise "`group/3` cannot be nested"

      @group_path unquote(path)

      @groups unquote(group)
      unquote(block)
      @group_path nil
    end
  end

  @type preview() :: %{
          :details_mfa => {module(), atom(), list()},
          optional(:email) => Swoosh.Email.t(),
          :email_mfa => {module(), atom(), list()},
          :group => String.t() | nil,
          :path => String.t(),
          optional(:preview_details) => map()
        }

  # Evaluates a preview. It loads the results of email_mfa and details_mfa into the email
  # and preview_details properties respectively.
  @doc false
  @spec eval_preview(preview()) :: map() | list()
  def eval_preview(%{email: _email} = preview), do: preview

  def eval_preview(preview) do
    preview
    |> eval_email()
    |> eval_details()
  end

  defp eval_email(%{email_mfa: {module, fun, opts}} = preview) do
    Map.put(preview, :email, apply(module, fun, opts))
  end

  # Evaluates preview details. It loads the results of details_mfa into the
  # preview_details property.
  @doc false
  @spec eval_details(preview() | list(preview())) :: map() | list()
  def eval_details(%{preview_details: _details} = preview), do: preview

  def eval_details(%{details_mfa: {module, fun, opts}} = preview) do
    Map.put(preview, :preview_details, validate_preview_details!(module, fun, opts))
  end

  def eval_details(previews) when is_list(previews) do
    Enum.map(previews, fn %{details_mfa: _mfa} = preview ->
      eval_details(preview)
    end)
  end

  # Evaluates a preview and reads the attachment at a given index position.
  @doc false
  @spec read_email_attachment_at(preview(), integer()) ::
          {:error, :invalid_attachment | :not_found}
          | {:ok, %{content_type: String.t(), data: any}}
  def read_email_attachment_at(preview, index) do
    preview
    |> eval_preview()
    |> Map.get(:email)
    |> case do
      %{attachments: attachments} when length(attachments) > index ->
        case Enum.at(attachments, index) do
          %{data: data, content_type: content_type} when not is_nil(data) ->
            {:ok, %{content_type: content_type, data: data}}

          %{path: path, content_type: content_type} when not is_nil(path) ->
            {:ok, %{content_type: content_type, data: File.read!(path)}}

          _other ->
            {:error, :invalid_attachment}
        end

      _no_attachments ->
        {:error, :not_found}
    end
  end

  defp validate_preview_details!(module, fun \\ :preview_details, opts \\ []) do
    module
    |> apply(fun, opts)
    |> Keyword.validate!([:title, :description, tags: []])
    |> Map.new()
    |> tap(&ensure_title!/1)
  end

  defp ensure_title!(details) do
    unless Map.has_key?(details, :title) do
      raise """

      The `title` is required in preview_details/0. Make sure it's being returned:

         def preview_details, do: [title: "Welcome email"]

      """
    end
  end

  defp validate_path("/" <> path), do: path

  defp validate_path(path) when is_binary(path), do: path

  defp validate_path(path) do
    raise ArgumentError, "router paths must be strings, got: #{inspect(path)}"
  end

  @doc false
  def build_preview_path(nil, path), do: path
  def build_preview_path(group, path), do: "#{group}.#{path}"
end