SquatchMail.Web.Router (SquatchMail v0.1.1)

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. squatch_mail_dashboard handles this for you: the webhook route pipes through SquatchMail.SNS.RawBodyPlug, which reads the raw body into conn.assigns[:raw_body] before the controller runs. Hosts need to wire up nothing — not a :body_reader, not an endpoint change.

This works even though the host endpoint's Plug.Parsers runs first, because SNS delivers with Content-Type: text/plain; charset=UTF-8 and Plug.Parsers (with the usual [:urlencoded, :multipart, :json] parsers and pass: ["*/*"]) matches no parser for text/plain — so it passes the request through with the body still unread, leaving those bytes for RawBodyPlug to consume in SquatchMail's own pipeline. This is also why the earlier "endpoint-wide :body_reader, no router-level fix" framing was wrong for SNS: Plug.Parsers never invokes a :body_reader for text/plain at all, so an endpoint reader would never have fired for a real SNS request regardless.

Hosts that already capture the raw body themselves still work: if conn.assigns[:raw_body] is already set (e.g. via SquatchMail.SNS.RawBodyReader wired into the endpoint's Plug.Parsers :body_reader), RawBodyPlug leaves it untouched. That path is an optional belt-and-suspenders, no longer a requirement. If, somehow, the raw body is not captured at all, WebhookController logs an actionable error and falls back to re-encoding conn.params — not byte-identical to what SNS sent, so signature verification will fail; see SquatchMail.SNS.RawBodyPlug's moduledoc for the full 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.