_.--""--._
.' `.
/ .-""-. \
| / ,, \ |
| | (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:
Zero-config send observability. SquatchMail attaches to Swoosh's telemetry events (
[:swoosh, :deliver | :deliver_many, :stop | :exception]) at boot and captures every send your app makes through its existing Swoosh mailer — no adapter swap, no proxy, no code changes to your send path. This is the thing LaraSend and friends structurally cannot do, because they aren't living inside your BEAM node.SES event ingestion. An SNS-backed webhook pipeline (hand-verified SigV1/SigV2 signatures against
:public_key, noex_awsdependency) turns delivery, bounce, complaint, open, click, reject, and delay notifications into a per-email timeline of Footprints, and automatically maintains your suppression list (hard bounces and complaints are permanent; soft bounces expire). Every inbound payload is logged for audit regardless of outcome.One-click SES provisioning. "Connect SES" from Base Camp creates the configuration set, SNS topic, HTTPS subscription, and event destination for you via
AWS.SESv2/AWS.SNS— the manual afternoon LaraSend asks you to spend in the AWS console, done from a button.Identity + DKIM guidance, with live DNS re-checks. List your sending identities, see verification and DKIM status, and get copy-paste DNS records (CNAME for DKIM, TXT for SPF/DMARC) instead of AWS's own documentation tabs. A "re-check DNS" button resolves each expected record against public DNS (
SquatchMail.SES.check_dns/2, via OTP's:inet_res— no new dependency) and reports pass/warn/missing per record, alongside SES's own verification verdict. Quota sync, cached for six hours, so Base Camp doesn't hammerGetAccounton every page load.Status that only ever tells the truth. A later, weaker event can never quietly downgrade an email's status — a delivery notification arriving after a click is recorded as a Footprint but doesn't un-click the email, and bounces/complaints/rejections always win outright. See
SquatchMail.Tracker.next_status/2if you want to see the ranking.Guardrails, not just observation.
SquatchMail.Guardchecks every send against the suppression list and a complaint-rate circuit breaker (auto-pauses sending at a 0.1% complaint rate by default — SES's own account-suspension threshold — with a minimum-volume floor so five sends and one complaint doesn't read as a 20% rate). Most hosts only needSquatchMail.Capture's pure observation, butSquatchMail.Adapters.Watchtoweris an opt-in Swoosh adapter for hosts who want a suppressed recipient or an auto-paused account to actually block the send, not just get recorded after the fact.It cleans up after itself.
SquatchMail.PrunerrunsSquatchMail.Tracker.prune/0on a timer (six hours by default), deleting emails and their footprints past your configuredretention_daysand webhook audit logs past a fixed 30-day window, so the forest doesn't just keep accumulating footprints forever.Your database, your rules. Every table lives inside its own
squatch_mailPostgres schema (configurable), versioned and migrated the same way Oban and ErrorTracker do it — one host-owned migration file that callsSquatchMail.Migrations.up(), safe to re-run as new versions ship.The dashboard itself. Trail Log (live activity feed with stats, filters, and CSV export), the Sightings archive, dedicated Bounces and Complaints views, the per-email Sighting inspector (preview, headers, Footprint timeline, raw), the Do-Not-Disturb registry, and Base Camp — all served from one router macro with self-contained assets.
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 calls | A hosted SES-wrapper API | SquatchMail | |
|---|---|---|---|
| Send observability | Nothing — you get a message_id and silence | Usually yes, on their servers | Yes, in your Postgres, via Swoosh telemetry — zero code changes |
| Bounce/complaint handling | Wire up SNS yourself, from scratch | Built in | Built in, hand-verified signatures, no proxy |
| Suppression list | Build and enforce it yourself | Built in | Built in, with bounce-type-aware expiry, plus an optional adapter that blocks the send outright |
| Where your data lives | Nowhere (SES doesn't keep a timeline) | Their database, their retention policy | Your database, your schema, your retention_days |
| Setup | AWS console, by hand | Sign up, get an API key, integrate their SDK | mix igniter.install squatch_mail |
| Extra infrastructure | None, but also none of the above | A vendor dependency | None — 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
With igniter (recommended)
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:
Add the dependency to
mix.exs:def deps do [ {:squatch_mail, "~> 0.1"} ] endThen run
mix deps.get.Configure SquatchMail in
config/config.exs:config :squatch_mail, repo: MyApp.Repo, otp_app: :my_app, prefix: "squatch_mail":repois required — it's theEcto.RepoSquatchMail uses to read and write its own tables, which live in their ownsquatch_mailPostgres schema so they never collide with your application's tables. SeeSquatchMail.Configfor all supported options, including the telemetry capture engine's:captureoptions (HTML/text body retention, sample rate), the guardrails':guardoptions (complaint-rate threshold, auto-pause), and:pruner(retention sweep interval).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() endThen run
mix ecto.migrate. Future SquatchMail releases that add tables or columns ship as new versions behind this same API —up()/down()with noversion:always resolve to "latest"/"initial", so this file never needs editing as SquatchMail evolves.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 endVisit
/squatchto see the dashboard. No other code changes are required — SquatchMail observes mail sent through Swoosh automatically via telemetry.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'sPlug.Parsershas already read and discarded the raw body.Plug.Parsers's:body_readeroption 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 ownendpoint.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_segmentsif 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_maildoes this step for you automatically (it generates the reader module and patches your endpoint'sPlug.Parserscall) when your endpoint looks like a standardmix phx.newendpoint. If yourPlug.Parsersoptions aren't a plain literal keyword list, or you already have a different:body_readerconfigured, 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]
endThis 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.
| Feature | Status | Notes |
|---|---|---|
| Zero-config Swoosh telemetry capture | Shipped | SquatchMail.Capture — LaraSend has no equivalent |
| Versioned migrations (Oban/ErrorTracker pattern) | Shipped | SquatchMail.Migrations, schema-comment version tracking |
| Core schema (emails, recipients, attachments, events, suppressions, webhook logs, source) | Shipped | SquatchMail.Tracker context |
| SES event ingestion (SNS webhook, signature verification, event normalizer) | Shipped | SquatchMail.SNS.MessageVerifier/Processor, hand-rolled signatures, no ex_aws |
| Suppression list (hard bounce/complaint permanent, soft bounce expiring) | Shipped | enforced in SquatchMail.Tracker and the SNS processor |
| One-click SES provisioning (config set + SNS topic + subscription) | Shipped | SquatchMail.SES.provision/1 — LaraSend requires manual console setup |
| SES quota sync (6h cache) | Shipped | SquatchMail.SES.ensure_quota_synced/1 |
| Identity list + DKIM/verification status + DNS record guidance | Shipped | SquatchMail.SES.list_identities/1, dns_records_for/1 |
| Live DNS re-check | Shipped | SquatchMail.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) | Shipped | SquatchMail.Web.Router — one macro, embedded assets, three auth layers |
| Activity feed + email inspector + stats | Shipped | Trail Log, Sightings archive, Sighting inspector |
| Suppressions / bounces / complaints / settings pages | Shipped | Do-Not-Disturb registry, Bounces/Complaints views, Base Camp |
| Complaint-rate auto-pause circuit breaker | Shipped | SquatchMail.Guard.check/1, min-volume floor, 0.1% default threshold |
| Send-path enforcement (optional) | Shipped | SquatchMail.Adapters.Watchtower — opt-in Swoosh adapter, blocks rather than only observes |
| Retention pruning | Shipped | SquatchMail.Pruner runs Tracker.prune/0 on a timer; also prunes webhook_logs on a fixed 30-day window |
| Igniter installer + manual install path | Shipped | mix igniter.install squatch_mail |
| Credential encryption at rest (static mode) | Planned | see "Keeping the Forest Safe" |
| Templates, workspaces, API keys, outbound webhooks, multi-project | — | P2 (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:
The test suite.
mix test— creates and migrates its ownsquatch_mail_testdatabase. Run it before sending a patch.The dashboard preview.
mix devboots a minimal Phoenix endpoint with the dashboard at http://localhost:4000/squatch, backed by asquatch_mail_devdatabase that's created and migrated automatically. This is the fast loop for dashboard/UI work. Run it asiex -S mix devand you can send emails through the preview's Swoosh mailer (SquatchMailDev.Mailer) to watch them flow through the capture pipeline — see the header ofdev.exsfor a copy-paste snippet.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_hostThis generates
test_host/(gitignored) withmix phx.new, adds SquatchMail as a path dependency pointing back at your checkout, runsmix squatch_mail.install— so it doubles as a smoke test of the igniter installer against a stock Phoenix app — and writes aDockerfile+docker-compose.ymlpinned to the latest stable Elixir/OTP and Postgres. Then:cd test_host docker compose up --build(No Docker?
mix phx.serverworks too, against whatever Postgres yourPG*variables point at.)The dashboard is at
/squatch, Swoosh's local mailbox at/dev/mailbox; send mail throughTestHost.Mailerand 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.