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