Packages

A self-hosted Amazon SES email dashboard for Phoenix apps, shipped as an embeddable Hex package.

Current section

Files

Jump to
squatch_mail lib mix tasks squatch_mail.install.ex
Raw

lib/mix/tasks/squatch_mail.install.ex

defmodule Mix.Tasks.SquatchMail.Install.Docs do
@moduledoc false
def short_doc do
"Install and configure SquatchMail for use in this application."
end
def example do
"mix squatch_mail.install"
end
def long_doc do
"""
#{short_doc()}
Adds SquatchMail's configuration to your `config.exs`, generates a
migration that creates its tables (in their own `squatch_mail` Postgres
schema), and mounts the dashboard in your Phoenix router.
Safe to re-run: existing configuration, migrations, and router mounts are
detected and left untouched.
## Example
```bash
#{example()}
```
## Options
* `--dashboard-path` or `-d` - the path to mount the dashboard at.
Defaults to `/squatch`.
* `--no-dashboard` - skip mounting the dashboard router. Useful for
telemetry-capture-only installs where the dashboard will be mounted
by hand later (see the "Manual installation" section of the README).
"""
end
end
if Code.ensure_loaded?(Igniter) do
defmodule Mix.Tasks.SquatchMail.Install do
@shortdoc "#{__MODULE__.Docs.short_doc()}"
@moduledoc __MODULE__.Docs.long_doc()
use Igniter.Mix.Task
alias Igniter.Project.Config
@default_dashboard_path "/squatch"
# Common names hosts give a pipeline that gates access behind
# authentication/authorization. If the router already has one of these,
# we prefer mounting the dashboard behind it instead of the bare
# `:browser` pipeline.
@likely_auth_pipelines ~w(
require_authenticated_user
admin
require_admin
authenticated
internal
)a
@impl Igniter.Mix.Task
def info(_argv, _composing_task) do
%Igniter.Mix.Task.Info{
group: :squatch_mail,
adds_deps: [],
installs: [],
example: __MODULE__.Docs.example(),
only: nil,
positional: [],
composes: [],
schema: [
dashboard_path: :string,
dashboard: :boolean
],
defaults: [
dashboard_path: @default_dashboard_path,
dashboard: true
],
aliases: [d: :dashboard_path],
required: []
}
end
@impl Igniter.Mix.Task
def igniter(igniter) do
app_name = Igniter.Project.Application.app_name(igniter)
{igniter, repo} = Igniter.Libs.Ecto.select_repo(igniter)
{igniter, router} = Igniter.Libs.Phoenix.select_router(igniter)
dashboard_path = igniter.args.options[:dashboard_path] || @default_dashboard_path
mount_dashboard? = Keyword.get(igniter.args.options, :dashboard, true)
igniter =
igniter
|> set_up_configuration(app_name, repo)
|> set_up_formatter()
|> set_up_database(repo)
|> maybe_set_up_web_ui(mount_dashboard?, router, dashboard_path)
# No endpoint patching is needed for SNS raw-body capture:
# `squatch_mail_dashboard`'s macro pipes the webhook route through
# `SquatchMail.SNS.RawBodyPlug`, which reads the raw body itself (SNS
# sends `text/plain`, which the host's `Plug.Parsers` passes through
# unread — see `SquatchMail.Web.Router`'s "Webhook raw body" moduledoc).
Igniter.add_notice(igniter, next_steps_notice(mount_dashboard?, dashboard_path))
end
defp set_up_configuration(igniter, app_name, repo) do
igniter
|> Config.configure_new("config.exs", :squatch_mail, [:repo], repo)
|> Config.configure_new("config.exs", :squatch_mail, [:otp_app], app_name)
|> Config.configure_new("config.exs", :squatch_mail, [:prefix], "squatch_mail")
|> Config.configure_new("config.exs", :squatch_mail, [:enabled], true)
end
defp set_up_formatter(igniter) do
Igniter.Project.Formatter.import_dep(igniter, :squatch_mail)
end
# `Igniter.Libs.Ecto.gen_migration/3` with `on_exists: :skip` already makes
# this step idempotent: a second run finds the existing
# `*_add_squatch_mail.exs` migration (matched by name, not timestamp) and
# leaves it alone rather than generating a duplicate.
defp set_up_database(igniter, repo) do
migration_body = """
def up, do: SquatchMail.Migrations.up()
def down, do: SquatchMail.Migrations.down()
"""
Igniter.Libs.Ecto.gen_migration(igniter, repo, "add_squatch_mail",
body: migration_body,
on_exists: :skip
)
end
defp maybe_set_up_web_ui(igniter, false, _router, _dashboard_path), do: igniter
defp maybe_set_up_web_ui(igniter, true, router, dashboard_path) do
set_up_web_ui(igniter, router, dashboard_path)
end
defp set_up_web_ui(igniter, nil, _dashboard_path) do
Igniter.add_warning(igniter, """
No Phoenix router found or selected. SquatchMail's configuration and \
migration were still set up, but the dashboard was not mounted.
Please add the following to your router by hand once Phoenix is set up:
import SquatchMail.Web.Router
scope "/" do
pipe_through :browser
squatch_mail_dashboard #{inspect(@default_dashboard_path)}
end
Or run this installer again with:
mix igniter.install squatch_mail
""")
end
defp set_up_web_ui(igniter, router, dashboard_path) do
if dashboard_already_mounted?(igniter, router) do
igniter
else
{igniter, auth_pipeline} = find_auth_pipeline(igniter, router)
do_mount_dashboard(igniter, router, dashboard_path, auth_pipeline)
end
end
# Idempotency guard: if `squatch_mail_dashboard/1,2` is already called
# anywhere in the router, don't add a second mount.
defp dashboard_already_mounted?(igniter, router) do
{_igniter, _source, zipper} = Igniter.Project.Module.find_module!(igniter, router)
case Igniter.Code.Function.move_to_function_call(
zipper,
:squatch_mail_dashboard,
[1, 2],
fn _ -> true end
) do
{:ok, _zipper} -> true
_ -> false
end
end
defp find_auth_pipeline(igniter, router) do
Enum.reduce_while(@likely_auth_pipelines, {igniter, nil}, fn name, {igniter, nil} ->
case Igniter.Libs.Phoenix.has_pipeline(igniter, router, name) do
{igniter, true} -> {:halt, {igniter, name}}
{igniter, false} -> {:cont, {igniter, nil}}
end
end)
end
defp do_mount_dashboard(igniter, router, dashboard_path, nil) do
igniter
|> import_web_router(router)
|> Igniter.Libs.Phoenix.append_to_scope(
"/",
"squatch_mail_dashboard #{inspect(dashboard_path)}",
router: router,
with_pipelines: [:browser],
placement: :after
)
|> Igniter.add_warning("""
SquatchMail's dashboard was mounted behind the plain `:browser` pipeline \
because no authenticated/admin pipeline (tried: \
#{Enum.join(@likely_auth_pipelines, ", ")}) was found in your router.
This means #{dashboard_path} has no access control. Before deploying,
configure one of:
* an `on_mount` authentication hook (see the `SquatchMail.Web.Router`
moduledoc for the callback contract),
* HTTP Basic Auth via the `:basic_auth` config option, or
* `:allow_unauthenticated` if this is intentional (e.g. a
network-isolated internal tool).
""")
end
defp do_mount_dashboard(igniter, router, dashboard_path, auth_pipeline) do
igniter
|> import_web_router(router)
|> Igniter.Libs.Phoenix.append_to_scope(
"/",
"squatch_mail_dashboard #{inspect(dashboard_path)}",
router: router,
with_pipelines: [auth_pipeline],
placement: :after
)
end
defp import_web_router(igniter, router) do
Igniter.Project.Module.find_and_update_module!(igniter, router, fn zipper ->
case Igniter.Code.Function.move_to_function_call(
zipper,
:import,
1,
&Igniter.Code.Function.argument_equals?(&1, 0, SquatchMail.Web.Router)
) do
{:ok, _zipper} ->
{:ok, zipper}
_ ->
{:ok,
Igniter.Code.Common.add_code(zipper, "import SquatchMail.Web.Router",
placement: :before
)}
end
end)
end
defp next_steps_notice(mount_dashboard?, dashboard_path) do
dashboard_line =
if mount_dashboard? do
" * Visit #{dashboard_path} after running migrations — the Squatch is watching your outbox now.\n"
else
""
end
"""
SquatchMail is configured. Next steps:
* Review the generated migration, then run:
mix ecto.migrate
#{dashboard_line}
* SquatchMail observes mail sent through Swoosh automatically via \
telemetry — no mailer changes required.
* The SNS webhook route captures its own raw request body (for \
signature verification) via SquatchMail.SNS.RawBodyPlug — no endpoint \
changes required.
"""
end
end
else
defmodule Mix.Tasks.SquatchMail.Install do
@shortdoc "#{__MODULE__.Docs.short_doc()} | Install `igniter` to use"
@moduledoc __MODULE__.Docs.long_doc()
use Mix.Task
def run(_argv) do
Mix.shell().error("""
The task 'squatch_mail.install' requires igniter. Please install igniter and try again.
For more information, see: https://hexdocs.pm/igniter/readme.html#installation
""")
exit({:shutdown, 1})
end
end
end