<!-- ASCII sasquatch, because the module names are not allowed to have any fun -->
```
_.--""--._
.' `.
/ .-""-. \
| / ,, \ |
| | (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`, no `ex_aws` dependency) 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 hammer `GetAccount` on 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/2` if you want to see the ranking.
- **Guardrails, not just observation.** `SquatchMail.Guard` checks 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 need
`SquatchMail.Capture`'s pure observation, but `SquatchMail.Adapters.Watchtower`
is 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.Pruner` runs
`SquatchMail.Tracker.prune/0` on a timer (six hours by default), deleting
emails and their footprints past your configured `retention_days` and
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_mail` Postgres schema (configurable), versioned and migrated the
same way Oban and ErrorTracker do it — one host-owned migration file that
calls `SquatchMail.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`](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](https://hexdocs.pm/igniter), add
SquatchMail and run its installer in one step:
```bash
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`:
```elixir
def deps do
[
{:squatch_mail, "~> 0.1"}
]
end
```
Then run `mix deps.get`.
2. **Configure SquatchMail** in `config/config.exs`:
```elixir
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:
```elixir
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:
```elixir
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:
```elixir
# 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:
```elixir
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
```elixir
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`](SECURITY.md) for how to
report it.
## Feature parity checklist
Tracking against the [LaraSend](https://larasend.com/) feature inventory
documented in [`FEATURES.md`](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](https://hexdocs.pm/squatch_mail) 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`](RESEARCH.md) (architecture and ecosystem survey),
[`FEATURES.md`](FEATURES.md) (feature inventory vs. LaraSend), and
[`DESIGN.md`](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`](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`](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](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:
```bash
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:
```bash
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`](LICENSE).