_.--""--._
.' `.
/ .-""-. \
| / ,, \ |
| | (OO) | | "It leaves footprints.
\ \ '' / / You leave... an
`. `""""' .' Ecto migration."
`--..____..--'
.-' / \ `-.
/ / \ \
| | | |
\ \ / /
`. `--' .'
`-.______.-'
_| |_
(_, ,_)
/'-. .-'\ /'-. .-'\
/ '-. .-' \ / '-. .-' \
'. ' '.' ' .'

SquatchMail

Big footprint. Tiny bill.

The self-hosted Amazon SES dashboard for Elixir. Your emails are out there. SquatchMail finds them.

SquatchMail is a self-hosted Amazon SES email dashboard for Phoenix applications, shipped as an embeddable Hex package — the ErrorTracker / Oban Web model, not a Docker Compose stack you have to babysit. Add the dependency, run one migration, mount one route, and every email your app sends through SES gets a live activity feed, delivery/open/click tracking, bounce and complaint handling, and a suppression list, all backed by tables that live quietly in their own Postgres schema inside your existing database.

No queue to stand up. No Redis. No separate service to deploy, monitor, and eventually forget about. If your Phoenix app already talks to SES, SquatchMail is mostly just... there, the way a footprint is there whether or not you were looking for it.

Why "Squatch"

Every email you send is a Sighting. Every SES event it generates — delivered, opened, clicked, bounced, complained about — is a Footprint. The suppression list is the Do-Not-Disturb Registry, because some addresses have asked, politely or via a hard bounce, to be left alone. The SES connection screen is Base Camp. The activity feed is the Trail Log. You are, in effect, running a small cryptozoology research station for your own outbound mail. We take this exactly as seriously as it deserves and not one degree more — the code underneath (Email, EmailEvent, Suppression) is boring on purpose. The bigfoot is a costume the UI wears, not a religion the codebase practices.

If a Sighting goes quiet in the woods, you'll know within a COMMENT ON TABLE-tracked schema migration.

FIELD EVIDENCE

What the expedition has actually turned up so far:

Still being tracked, not yet a confirmed sighting: credential encryption at rest for static-mode AWS keys. See the checklist below and FEATURES.md for the full inventory.

What raw SES makes you build yourself

Raw AWS.SESv2 callsA hosted SES-wrapper APISquatchMail
Send observabilityNothing — you get a message_id and silenceUsually yes, on their serversYes, in your Postgres, via Swoosh telemetry — zero code changes
Bounce/complaint handlingWire up SNS yourself, from scratchBuilt inBuilt in, hand-verified signatures, no proxy
Suppression listBuild and enforce it yourselfBuilt inBuilt in, with bounce-type-aware expiry, plus an optional adapter that blocks the send outright
Where your data livesNowhere (SES doesn't keep a timeline)Their database, their retention policyYour database, your schema, your retention_days
SetupAWS console, by handSign up, get an API key, integrate their SDKmix igniter.install squatch_mail
Extra infrastructureNone, but also none of the aboveA vendor dependencyNone — it's a library, not a service

TRACKING METHODOLOGY

How a Sighting actually gets tracked, in two halves — outbound and inbound:

OUTBOUND (a send happens)
──────────────────────────
Your app SquatchMail Your database
┌────────────┐ deliver ┌───────────────┐ record ┌─────────────────┐
│ Swoosh │ ───────▶ │ :telemetry │ ────────▶ │ squatch_mail.* │
│ Mailer │ (:stop) │ span capture │ (async, │ (emails, │
│ .deliver/2 │ │ (Capture) │ never │ recipients, │
└────────────┘ └───────────────┘ blocks) │ attachments) │
└─────────────────┘
no adapter swap · no proxy · your existing SES call, observed
INBOUND (SES reports back what happened to it)
───────────────────────────────────────────────
┌─────┐ event ┌─────┐ HTTPS POST ┌──────────────────┐ verified, ┌─────────────────┐
│ SES │ ────────▶ │ SNS │ ───────────▶ │ webhook endpoint │ ───────────▶ │ squatch_mail.* │
└─────┘ └─────┘ (signed) │ (token + SigV1/2 │ normalized │ (email_events = │
│ signature check) │ event │ "Footprints", │
└──────────────────┘ │ suppressions) │
└─────────────────┘
delivery → delivered · open → opened · click → clicked
bounce → bounced (+ suppression) · complaint → complained (+ suppression)

Both halves land in the same squatch_mail Postgres schema, which the dashboard reads from directly. No queue, no separate service, no polling.

SETTING UP BASE CAMP

If your project already uses igniter, add SquatchMail and run its installer in one step:

mix igniter.install squatch_mail

This adds :squatch_mail to mix.exs, configures it in config.exs, generates the migration that creates its tables, mounts the dashboard in your router at /squatch, and teaches your endpoint to preserve the raw bytes SNS webhook signatures need (see step 5 of the manual path below for what this actually does, and why it's the one thing that can't be avoided).

Manual installation

If you'd rather not use igniter, or want full control over each step:

  1. Add the dependency to mix.exs:

    def deps do
    [
    {:squatch_mail, "~> 0.1"}
    ]
    end

    Then run mix deps.get.

  2. Configure SquatchMail in config/config.exs:

    config :squatch_mail,
    repo: MyApp.Repo,
    otp_app: :my_app,
    prefix: "squatch_mail"

    :repo is required — it's the Ecto.Repo SquatchMail uses to read and write its own tables, which live in their own squatch_mail Postgres schema so they never collide with your application's tables. See SquatchMail.Config for all supported options, including the telemetry capture engine's :capture options (HTML/text body retention, sample rate), the guardrails' :guard options (complaint-rate threshold, auto-pause), and :pruner (retention sweep interval).

  3. Generate and run the migration. Create a new migration in your host app (mix ecto.gen.migration add_squatch_mail) with:

    defmodule MyApp.Repo.Migrations.AddSquatchMail do
    use Ecto.Migration
    def up, do: SquatchMail.Migrations.up()
    def down, do: SquatchMail.Migrations.down()
    end

    Then run mix ecto.migrate. Future SquatchMail releases that add tables or columns ship as new versions behind this same API — up()/down() with no version: always resolve to "latest"/"initial", so this file never needs editing as SquatchMail evolves.

  4. Mount the dashboard in your router:

    defmodule MyAppWeb.Router do
    use MyAppWeb, :router
    import SquatchMail.Web.Router
    scope "/" do
    pipe_through :browser
    squatch_mail_dashboard "/squatch"
    end
    end

    Visit /squatch to see the dashboard. No other code changes are required — SquatchMail observes mail sent through Swoosh automatically via telemetry.

  5. Teach your endpoint to preserve the evidence. SquatchMail's SNS webhook needs the exact bytes SNS sent to verify the request's signature — but by the time a router (including squatch_mail_dashboard's own macro) sees a request, your endpoint's Plug.Parsers has already read and discarded the raw body. Plug.Parsers's :body_reader option is endpoint-wide, not per-route, so this is the one piece of wiring the installer/router genuinely cannot do for you — it has to happen in your own endpoint.ex, before the router plug:

    # in your endpoint.ex
    defmodule MyAppWeb.SquatchMailBodyReader do
    @path_segments ["squatch"]
    def read_body(conn, opts) do
    if webhook_path?(conn.path_info) do
    SquatchMail.SNS.RawBodyReader.read_body(conn, opts)
    else
    Plug.Conn.read_body(conn, opts)
    end
    end
    defp webhook_path?(path_info) do
    prefix = @path_segments
    case Enum.split(path_info, length(prefix)) do
    {^prefix, ["webhooks", "sns", _token]} -> true
    _ -> false
    end
    end
    end
    plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Phoenix.json_library(),
    body_reader: {MyAppWeb.SquatchMailBodyReader, :read_body, []}

    Adjust @path_segments if you mounted the dashboard somewhere other than /squatch. Skip this step and every real SNS notification will fail signature verification — the webhook falls back to re-encoding the parsed params as JSON, which isn't byte-identical to what SNS sent.

    mix igniter.install squatch_mail does this step for you automatically (it generates the reader module and patches your endpoint's Plug.Parsers call) when your endpoint looks like a standard mix phx.new endpoint. If your Plug.Parsers options aren't a plain literal keyword list, or you already have a different :body_reader configured, the installer won't guess — it leaves your endpoint untouched and prints this exact snippet as a notice instead.

    Read the "Keeping the Forest Safe" section below before you deploy this anywhere but your own laptop.

KEEPING THE FOREST SAFE

SquatchMail ships three layers of dashboard access control (SquatchMail.Web.Router + SquatchMail.Web.Plugs.Auth). Exactly one applies to any given request to a dashboard page (Trail Log, Sightings, Suppressions, Base Camp): a configured :basic_auth wins over everything; otherwise a host-supplied :on_mount means the host owns auth; otherwise the dashboard refuses to render. The inbound SNS webhook route is never covered by any of them — it authenticates itself independently (see below).

a) Host-owned authentication (recommended). Mount squatch_mail_dashboard inside your own authenticated 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

This is the only layer that can express real authorization — roles, per-user scoping, SSO. Layers (b) and (c) are meant as a safety net for hosts that mount the dashboard without wiring up their own auth, not a substitute for doing so. Note that both the pipe_through and the :on_mount hook matter: the plug pipeline gates the initial HTTP request, and the hook re-checks on the LiveView socket — see the SquatchMail.Web.Router moduledoc for why a plug alone can't protect the websocket after mount.

b) Built-in fallback: HTTP Basic Auth. Configure

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

and every dashboard route is protected by Plug.BasicAuth (a real 401 with a www-authenticate challenge) — for small deployments that want something stronger than wide open without standing up a real admin pipeline. When set, this takes precedence over everything else, including a configured :on_mount — setting :basic_auth is an explicit, unambiguous request for that gate.

c) Safe default: refuse. If neither (a) nor (b) applies, SquatchMail checks Application.get_env(:squatch_mail, :allow_unauthenticated, false) — a runtime flag, not Mix.env(), which doesn't exist in a release and would silently disable this exact safeguard in production. Unless that flag is explicitly true (fine in dev.exs; never set it in production), every dashboard request is halted with a 403 refusal page that explains the three options above instead of rendering any data.

The SNS webhook.SquatchMail.SNS.MessageVerifier hand-verifies inbound SNS message signatures (SigV1/SigV2) against :public_key, with no third-party dependency, validating the SigningCertURL host/scheme before ever fetching it and caching parsed certificates in ETS for the certificate's own validity window. SquatchMail.SNS.Processor rejects a payload with a missing or invalid signature before it can touch your data, and every inbound payload — verified or not — is logged via SquatchMail.Tracker.log_webhook/1 for audit. This does not depend on the dashboard auth layers above; it's independent token-plus-signature authentication for a machine-to-machine endpoint.

Credentials at rest. AWS credentials for SES/SNS provisioning are either read from the environment (credentials_mode: "ambient", the default — no keys touch your database) or, if you opt into credentials_mode: "static", stored as plaintext columns on the sources table today. Encrypting access_key_id/secret_access_key at rest is a known gap, tracked as a TODO in SquatchMail.Source — prefer ambient credentials until that lands. Note that ambient mode reads AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/ AWS_SESSION_TOKEN from the environment only — it does not resolve EC2 instance or ECS task role credentials via the metadata service (a documented follow-up in SquatchMail.SES). On a role-based deployment, export the credentials into the environment or inject your own %AWS.Client{}.

Found a security issue? See SECURITY.md for how to report it.

Feature parity checklist

Tracking against the LaraSend feature inventory documented in FEATURES.md. P1 = this embeddable library; P2 = a future standalone app; = intentionally out of scope for P1. Status here reflects what's actually committed to main, not what's in an open pull request or a teammate's working tree.

FeatureStatusNotes
Zero-config Swoosh telemetry captureShippedSquatchMail.Capture — LaraSend has no equivalent
Versioned migrations (Oban/ErrorTracker pattern)ShippedSquatchMail.Migrations, schema-comment version tracking
Core schema (emails, recipients, attachments, events, suppressions, webhook logs, source)ShippedSquatchMail.Tracker context
SES event ingestion (SNS webhook, signature verification, event normalizer)ShippedSquatchMail.SNS.MessageVerifier/Processor, hand-rolled signatures, no ex_aws
Suppression list (hard bounce/complaint permanent, soft bounce expiring)Shippedenforced in SquatchMail.Tracker and the SNS processor
One-click SES provisioning (config set + SNS topic + subscription)ShippedSquatchMail.SES.provision/1 — LaraSend requires manual console setup
SES quota sync (6h cache)ShippedSquatchMail.SES.ensure_quota_synced/1
Identity list + DKIM/verification status + DNS record guidanceShippedSquatchMail.SES.list_identities/1, dns_records_for/1
Live DNS re-checkShippedSquatchMail.SES.check_dns/2 resolves expected records via :inet_res; wired to Base Camp's "re-check DNS" button alongside the SES-side recheck_identity/1
Dashboard foundation (router macro, auth, layout, self-contained assets)ShippedSquatchMail.Web.Router — one macro, embedded assets, three auth layers
Activity feed + email inspector + statsShippedTrail Log, Sightings archive, Sighting inspector
Suppressions / bounces / complaints / settings pagesShippedDo-Not-Disturb registry, Bounces/Complaints views, Base Camp
Complaint-rate auto-pause circuit breakerShippedSquatchMail.Guard.check/1, min-volume floor, 0.1% default threshold
Send-path enforcement (optional)ShippedSquatchMail.Adapters.Watchtower — opt-in Swoosh adapter, blocks rather than only observes
Retention pruningShippedSquatchMail.Pruner runs Tracker.prune/0 on a timer; also prunes webhook_logs on a fixed 30-day window
Igniter installer + manual install pathShippedmix igniter.install squatch_mail
Credential encryption at rest (static mode)Plannedsee "Keeping the Forest Safe"
Templates, workspaces, API keys, outbound webhooks, multi-projectP2 (standalone app) scope, not P1

Requirements

Elixir 1.15+, Ecto 3.13+, Phoenix LiveView 1.0+, PostgreSQL.

Documentation

Module docs are on HexDocs once published; until then, mix docs builds them locally. Start with SquatchMail.Config for configuration, SquatchMail.Tracker for the read/write API the dashboard and webhook layers are built on, SquatchMail.Migrations for the migration contract, and SquatchMail.SES for the "Connect SES" provisioning flow.

See also RESEARCH.md (architecture and ecosystem survey), FEATURES.md (feature inventory vs. LaraSend), and DESIGN.md (the dashboard's visual design spec and the full Sighting/Footprint/Base Camp glossary) for everything that doesn't belong in module docs. CHANGELOG.md tracks what's shipped release over release.

JOIN THE EXPEDITION

Issues and pull requests are welcome — this is early, pre-1.0 work. Read CLAUDE.md for the naming conventions this codebase holds itself to (boring code, bigfoot-flavored UI copy only) before sending a patch, and see SECURITY.md if what you found is a vulnerability rather than a bug.

Working on SquatchMail locally

You need Elixir 1.15+ and a Postgres to point at — either one you run yourself (connection settings honor the standard PGUSER/PGPASSWORD/PGHOST/PGPORT environment variables) or the Dockerized one the test host brings along (see loop 3; once it exists, PGPORT=5433 PGUSER=postgres PGPASSWORD=postgres points loops 1 and 2 at it, no local Postgres install required). Three loops, from fastest to most realistic:

  1. The test suite.mix test — creates and migrates its own squatch_mail_test database. Run it before sending a patch.

  2. The dashboard preview.mix dev boots a minimal Phoenix endpoint with the dashboard at http://localhost:4000/squatch, backed by a squatch_mail_dev database that's created and migrated automatically. This is the fast loop for dashboard/UI work. Run it as iex -S mix dev and you can send emails through the preview's Swoosh mailer (SquatchMailDev.Mailer) to watch them flow through the capture pipeline — see the header of dev.exs for a copy-paste snippet.

  3. A real host app. For anything touching the installer, migrations, or the host-integration story, scaffold a throwaway Phoenix app that embeds SquatchMail the way a real project would:

    bin/setup_test_host

    This generates test_host/ (gitignored) with mix phx.new, adds SquatchMail as a path dependency pointing back at your checkout, runs mix squatch_mail.install — so it doubles as a smoke test of the igniter installer against a stock Phoenix app — and writes a Dockerfile + docker-compose.yml pinned to the latest stable Elixir/OTP and Postgres. Then:

    cd test_host
    docker compose up --build

    (No Docker? mix phx.server works too, against whatever Postgres your PG* variables point at.)

    The dashboard is at /squatch, Swoosh's local mailbox at /dev/mailbox; send mail through TestHost.Mailer and it shows up in both. Your checkout is bind-mounted into the app container, so library changes are picked up on recompile — restart the app service. Delete the directory and re-run the script whenever you want a fresh host.

License

MIT — see LICENSE.