# `SquatchMail.Tracker`
[🔗](https://github.com/Axio-Intelligence/Squatch-Mail/blob/v0.1.0/lib/squatch_mail/tracker.ex#L1)

The persistence context for SquatchMail's observability data.

Everything the capture engine, webhook ingestion, and dashboard need to read
and write email records, events, suppressions, and source configuration lives
here. All database access goes through `SquatchMail.Config.repo/0` so the host
application's repo is used, and the configured `SquatchMail.Config.prefix/0`
schema keeps SquatchMail's tables isolated.

## Prefix handling

Every SquatchMail schema declares `@schema_prefix "squatch_mail"`, which Ecto
respects automatically for `Repo.insert/update/delete/all/get` and for queries
built from those schemas. As a result the context does **not** pass `prefix:`
redundantly on schema-based operations. `prefix:` is only supplied where Ecto
can't infer it — namely the raw version-tracking SQL in the migrator (which
passes it explicitly) — not in this module.

# `bounce_details`

```elixir
@spec bounce_details([integer()]) :: %{
  required(integer()) =&gt; %{
    bounce_type: String.t() | nil,
    bounce_subtype: String.t() | nil,
    diagnostic: String.t() | nil
  }
}
```

Returns bounce reason details per email, for a list of email ids, in a
single query.

For each id, the *latest* `"bounce"` event's SES payload is summarized as
`%{bounce_type: _, bounce_subtype: _, diagnostic: _}` (any of which may be
`nil` — legacy payloads and manual status changes don't always carry them).
`:diagnostic` is the `diagnosticCode` of the bounced recipient matching the
event's own recipient, falling back to the first bounced recipient. Ids
with no bounce event are absent from the result map.

Used by the dashboard's Bounces page for its Reason column, the same way
`engagement_counts/1` backs the Trail Log's engagement column.

# `complaint_details`

```elixir
@spec complaint_details([integer()]) :: %{
  required(integer()) =&gt; %{feedback_type: String.t() | nil}
}
```

Returns complaint details per email, for a list of email ids, in a single
query.

For each id, the *latest* `"complaint"` event's SES payload is summarized
as `%{feedback_type: _}` — the ISP's `complaintFeedbackType` (e.g.
`"abuse"`), which SES only includes when the reporting ISP provides it, so
it is often `nil`. Ids with no complaint event are absent from the result
map.

Used by the dashboard's Complaints page for its Feedback column.

# `engagement_counts`

```elixir
@spec engagement_counts([integer()]) :: %{
  required(integer()) =&gt; %{opens: non_neg_integer(), clicks: non_neg_integer()}
}
```

Returns open/click counts per email, for a list of email ids, in a single
aggregate query.

Used by list views (e.g. the Trail Log's engagement column) that need
per-row engagement counts without an N+1 query per row. Ids with no
`email_events` of type `"open"` or `"click"` are simply absent from the
result map — callers should default to `0` on lookup miss, e.g.
`Map.get(counts, id, %{opens: 0, clicks: 0})`.

# `get_email!`

```elixir
@spec get_email!(String.t()) :: SquatchMail.Email.t()
```

Fetches an email by its `public_id`, preloading `:recipients`, `:attachments`,
and `:events` (events ordered by `occurred_at` ascending).

Raises `Ecto.NoResultsError` when no email matches.

# `get_or_create_source`

```elixir
@spec get_or_create_source() :: SquatchMail.Source.t()
```

Returns the single source row, inserting a default one (with a generated
`webhook_token`) if none exists yet.

# `list_emails`

```elixir
@spec list_emails(map() | Keyword.t()) :: [SquatchMail.Email.t()]
```

Lists emails with `:recipients` preloaded, optionally filtered.

Supported filters:

  * `:status` - exact status match.
  * `:search` - case-insensitive match over subject, from_email, and
    recipient address (joins recipients only when set).
  * `:from_date` / `:to_date` - inclusive `inserted_at` bounds. Alternatively
    `:date_range` may be a `%{from: _, to: _}` map.
  * `:limit` / `:offset` - pagination (default limit 50).

# `list_suppressions`

```elixir
@spec list_suppressions(map() | Keyword.t()) :: [SquatchMail.Suppression.t()]
```

Lists suppressions, optionally filtered.

Supported filters: `:reason`, `:address`, `:limit`, `:offset`.

# `log_webhook`

```elixir
@spec log_webhook(map()) :: {:ok, SquatchMail.WebhookLog.t()} | {:error, changeset()}
```

Records an inbound webhook audit entry.

`attrs` follows `SquatchMail.WebhookLog`'s castable fields (`:provider`,
`:message_type`, `:status`, `:payload`, `:error`). Callers are expected to
log every inbound webhook payload regardless of outcome, so this always
inserts rather than upserting.

# `mark_email_sent`

```elixir
@spec mark_email_sent(SquatchMail.Email.t(), String.t(), DateTime.t()) ::
  {:ok, SquatchMail.Email.t()} | {:error, changeset()}
```

Marks an email as sent: sets `message_id`, `sent_at`, and status `"sent"`.

# `next_status`

```elixir
@spec next_status(String.t(), String.t()) :: String.t()
```

Computes the next email status given the current status and a proposed status.

Terminal-negative statuses (`bounced`, `complained`, `rejected`, `failed`)
always win. Otherwise the status only advances forward per the engagement rank;
a lower-ranked proposal is ignored. Unknown statuses are treated as rank 0.

# `prune`

```elixir
@spec prune() :: %{
  emails: non_neg_integer(),
  events: non_neg_integer(),
  webhook_logs: non_neg_integer()
}
```

Prunes data older than the source's `retention_days`.

Deletes `emails` whose `inserted_at` is older than the cutoff (cascading to
recipients/attachments and nilifying events via foreign keys), then deletes
now-orphaned `email_events` (with `nil` email_id) whose `occurred_at` is older
than the cutoff. Also deletes `webhook_logs` older than a fixed 30-day
window, independent of `retention_days`.

Returns `%{emails: count, events: count, webhook_logs: count}`.

# `record_email`

```elixir
@spec record_email(map()) :: {:ok, SquatchMail.Email.t()} | {:error, changeset()}
```

Records a captured email together with its recipients and attachments.

`attrs` is a map of email fields plus two optional nested lists:

  * `:recipients` - list of `%{kind, address, name}` maps.
  * `:attachments` - list of `%{filename, content_type, size, disposition}` maps.

Runs in a single transaction: the email is inserted (generating a `public_id`),
recipients and attachments are inserted, `has_attachments`/`attachments_count`
are derived from the attachment list, and any orphan `email_events` that
arrived before this email was known (matched by `message_id`) are back-linked
to the new email.

Returns `{:ok, email}` with `:recipients` and `:attachments` preloaded, or
`{:error, changeset}`.

# `record_event`

```elixir
@spec record_event(map()) :: {:ok, SquatchMail.EmailEvent.t()} | {:error, changeset()}
```

Records an email event and, when possible, links it to its email and advances
the email's status.

The event is always inserted. If it carries a `message_id` and a matching email
exists, the event's `email_id` is set and the email's status is advanced per
the event-type mapping using `next_status/2` (never regressing).

The matching email (if any) is re-fetched and row-locked (`SELECT ... FOR
UPDATE`) *inside* the transaction, immediately before computing the status
advance. This matters because SNS can deliver more than one notification
for the same `message_id` close together (e.g. a bounce and a complaint
arriving within milliseconds of each other, or a plain redelivered retry):
without the lock, two concurrent `record_event/1` calls could both read
the same starting status, both compute their own "next" status from it,
and have the second commit silently overwrite the first's advancement.
The row lock serializes the two — the second call's `FOR UPDATE` blocks
until the first transaction commits, then reads the *already-advanced*
status, so both events still land correctly relative to each other.

Returns `{:ok, event}` (with `email_id` set when linked) or
`{:error, changeset}`.

# `stats`

```elixir
@spec stats(%{from: DateTime.t(), to: DateTime.t()}) :: map()
```

Computes send/engagement counts and rates for a date range, plus deltas versus
the immediately-preceding equal-length period.

Accepts `%{from: DateTime.t(), to: DateTime.t()}`. Counts are taken over
`emails.inserted_at` within the range. Returns a map:

    %{
      current: %{sent: _, delivered: _, opened: _, clicked: _, bounced: _,
                 complained: _, total: _},
      previous: %{...same keys...},
      rates: %{delivered: float, opened: float, clicked: float,
               bounced: float, complained: float},
      deltas: %{sent: float, delivered: float, ...}  # percent change vs previous
    }

Rate denominators: `delivered` is over `total`; `opened`/`clicked` are over
`delivered` (engagement of what landed); `bounced`/`complained` are over
`total`. Deltas are percentage change of each count versus the prior period
(`nil` when the prior count is 0 and the current is 0; `+100.0`-style growth
when prior is 0 and current is positive is reported as `nil` to avoid dividing
by zero — callers render "new").

Counts are computed with two aggregate queries (one per period), each using
`count(*) FILTER (WHERE ...)`; there is no per-row looping in Elixir.

# `suppress`

```elixir
@spec suppress(map()) :: {:ok, SquatchMail.Suppression.t()} | {:error, changeset()}
```

Inserts or updates a suppression for an address.

Addresses are unique. If the address is already suppressed, its `reason`,
`event_type`, `expires_at`, and `notes` are updated (upsert) rather than
raising a constraint error.

# `suppressed?`

```elixir
@spec suppressed?(String.t()) :: boolean()
```

Returns `true` if a non-expired suppression exists for the address.

A suppression is active when `expires_at IS NULL` or `expires_at > now()`.

For checking many addresses at once (e.g. every recipient of a batch
send), prefer `suppressed_addresses/1` — one query for N addresses
instead of N queries.

# `suppressed_addresses`

```elixir
@spec suppressed_addresses([String.t()]) :: [String.t()]
```

Returns the subset of `addresses` that carry an active (non-expired)
suppression, in a single query.

A suppression is active when `expires_at IS NULL` or `expires_at >
now()`, same as `suppressed?/1`. Returns `[]` immediately without
querying when `addresses` is empty.

# `unsuppress`

```elixir
@spec unsuppress(String.t()) :: {:ok, non_neg_integer()}
```

Deletes any suppression row(s) for the given address. Returns `{:ok, count}`.

# `update_email_status`

```elixir
@spec update_email_status(SquatchMail.Email.t() | integer(), String.t()) ::
  {:ok, SquatchMail.Email.t()} | {:error, changeset()}
```

Updates an email's status. Accepts an `%Email{}` or its integer id.

# `update_source`

```elixir
@spec update_source(map()) :: {:ok, SquatchMail.Source.t()} | {:error, changeset()}
```

Updates the source row with the given attributes.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
