SquatchMail.Web.Router (SquatchMail v0.1.0)

Copy Markdown View Source

Mounts the SquatchMail dashboard in a host Phoenix router.

defmodule MyAppWeb.Router do
  use MyAppWeb, :router
  import SquatchMail.Web.Router

  scope "/" do
    pipe_through :browser
    squatch_mail_dashboard "/squatch"
  end
end

This expands to a Phoenix.LiveView.Router.live_session/3 wrapping seven routes — Trail Log (/, the live activity feed), the Sightings archive (/sightings), the Sighting inspector (/sightings/:public_id), Bounces (/bounces), Complaints (/complaints), the Do-Not-Disturb registry (/suppressions), and Base Camp (/base-camp) — plus a GET .../activity/export.csv CSV download (same auth as the dashboard), GET .../assets/* routes for the dashboard's self-contained CSS/JS and logo (never auth-gated), and a POST .../webhooks/sns/:token route that forwards to SquatchMail.Web.WebhookController (authenticated by its token, not the dashboard's auth layers).

Security

SquatchMail ships three layers of access control, in order of precedence. Exactly one applies for any given request to a dashboard route (Trail Log, Sightings, Suppressions, Base Camp); the SNS webhook route is never covered by any of them — it authenticates itself via its per-source :token path segment instead (signature verification happens inside SquatchMail.Web.WebhookController).

All three layers are enforced by SquatchMail.Web.Plugs.Auth, a plain Plug that runs before live_session in the ordinary Plug pipeline. This is deliberate: HTTP Basic Auth's 401 challenge and the refusal page in layer (c) both need to send a real HTTP status/headers before Phoenix commits to rendering a LiveView, which is only possible from a Plug — a LiveView on_mount hook only ever sees a Phoenix.LiveView.Socket and can at most redirect, never issue an arbitrary status code (see Phoenix.LiveView.Router's own documentation on this exact limitation). on_mount remains the right place for post-auth concerns — SquatchMail uses SquatchMail.Web.OnMount there only to assign theme/config, never to gate access.

Mount squatch_mail_dashboard inside your own authenticated/admin pipeline and pass your own on_mount hooks, exactly like Oban Web or Phoenix LiveDashboard:

scope "/" do
  pipe_through [:browser, :require_admin_user]
  squatch_mail_dashboard "/squatch", on_mount: [MyAppWeb.AdminAuth]
end

Passing :on_mount tells SquatchMail.Web.Plugs.Auth that the host is handling authorization itself further up the pipeline (in :require_admin_user above), so layers (b) and (c) both stand down regardless of their configuration. This is the only layer that can express arbitrary authorization (roles, per-user scoping, SSO, etc.) — layers (b) and (c) exist as a safety net for hosts that mount the dashboard without wiring up their own auth, not as a replacement for it.

b) Built-in fallback: HTTP Basic Auth

If the host configures

config :squatch_mail,
  basic_auth: [username: "squatch", password: System.fetch_env!("SQUATCH_MAIL_PASSWORD")]

then every dashboard route (never the SNS webhook route) is protected by Plug.BasicAuth with those credentials — this check takes precedence over everything else, including a configured :on_mount, since configuring :basic_auth is an explicit, unambiguous request for that gate.

c) Safe default: refuse in production

If neither (a) nor (b) applies — no :on_mount was given to squatch_mail_dashboard and no :basic_auth is configured — SquatchMail checks Application.get_env(:squatch_mail, :allow_unauthenticated, false).

  • In development, hosts are expected to be running locally, so leaving this unset is harmless and the dashboard mounts normally — set config :squatch_mail, allow_unauthenticated: true in dev.exs to make that explicit.
  • When unset (the default) and neither (a) nor (b) applies, dashboard routes instead render a refusal page explaining how to configure layer (a) or (b). This is a deliberately conservative default: an embeddable dashboard with no auth at all must never be one mix deps.get away from being reachable in production.

This check is a runtime Application.get_env/3 read, not Mix.env()Mix.env() does not exist in a compiled release, so gating on it would silently disable the safety net in exactly the environment (production) where it matters most.

Note that the refusal page's own CSS/JS still load: asset routes (.../assets/css-*, .../assets/js-*) are mounted outside the scope this plug covers, in any configuration, so the refusal page itself always renders correctly instead of appearing broken.

Webhook raw body

SNS signature verification (SquatchMail.SNS.MessageVerifier, used by SquatchMail.Web.WebhookController) needs the exact bytes SNS sent, not a round-tripped re-encoding of the parsed params. This is the one piece of wiring squatch_mail_dashboard genuinely cannot set up for you: by the time a router (this macro included) sees a request, the host application's own endpoint has already run Plug.Parsers and discarded the raw body. Plug.Parsers's :body_reader option has no per-route scoping — it's endpoint-wide — so there is no router-level fix.

You must add a path-conditional body_reader to your own endpoint, before the router plug, that delegates to SquatchMail.SNS.RawBodyReader only for the webhook path and falls through to the plain reader for everything else (including the rest of the dashboard, which doesn't need this):

# in your endpoint.ex
defmodule MyAppWeb.CacheBodyReader do
  def read_body(conn, opts) do
    if match?(["squatch", "webhooks", "sns", _token], conn.path_info) do
      SquatchMail.SNS.RawBodyReader.read_body(conn, opts)
    else
      Plug.Conn.read_body(conn, opts)
    end
  end
end

plug Plug.Parsers,
  parsers: [:urlencoded, :multipart, :json],
  pass: ["*/*"],
  json_decoder: Phoenix.json_library(),
  body_reader: {MyAppWeb.CacheBodyReader, :read_body, []}

Adjust the path prefix if you mount the dashboard somewhere other than /squatch. dev.exs and test/support/web_endpoint.ex in this repo carry this exact pattern (SquatchMailDev.CacheBodyReader / SquatchMail.Test.CacheBodyReader) as reference implementations, and test/squatch_mail/web/webhook_route_test.exs asserts the wiring preserves bytes exactly. If this step is skipped, WebhookController falls back to re-encoding conn.params as JSON — not byte-identical to what SNS sent — so signature verification will fail on every request; see SquatchMail.SNS.RawBodyReader's own moduledoc for the underlying detail.

Options

  • :on_mount - a list of on_mount hooks run before SquatchMail's own. Also signals to layer (c) that the host is handling auth (see above).
  • :as - the live_session name. Defaults to :squatch_mail_dashboard. live_session names must be unique per router module, not per scope — if you mount squatch_mail_dashboard more than once in the same router (e.g. one internal, one customer-facing instance), give every mount after the first a distinct :as.

Summary

Functions

Mounts the SquatchMail dashboard at path. See the moduledoc for the full security model.

Functions

squatch_mail_dashboard(path, opts \\ [])

(macro)

Mounts the SquatchMail dashboard at path. See the moduledoc for the full security model.