# `PhoenixKit.Modules.Emails.EventTracker`
[🔗](https://github.com/BeamLabEU/phoenix_kit_emails/blob/0.4.2/lib/phoenix_kit/modules/emails/event_tracker.ex#L1)

Behaviour every provider delivery-event tracker implements (SES, Brevo,
future Mailgun/…). See
`dev_docs/specs/2026-07-26-email-event-tracking-universalization-spec.md`
§4.1 for the full design rationale.

The lifecycle invariant every registered tracker must satisfy:

> iff `should_run?/1` (== `eligible?/1 and enabled?/1`), exactly one
> self-scheduling Oban chain is queued for `worker/1`; otherwise none.

`EventTrackerReconciler` is what actually enforces this — a tracker
only needs to answer the callbacks below honestly.

## `eligible?/1` vs `enabled?/1` — not the same gate

`eligible?/1` folds every *feature/source* gate: "does a working event
source exist for this provider at all". `enabled?/1` is *only* the
operator's polling on/off switch. Conflating them was the SES poller's
original bug (`SQSPollingJob.should_poll?/0` gates on three things —
`Emails.enabled?()`, `email_ses_events`, AND `sqs_polling_enabled` — not
two): `email_ses_events` is a feature flag ("is SES event tracking a
thing here at all"), which belongs in `eligible?/1`, not folded into
the polling toggle. Getting this split right is what lets the admin
panel (P2) distinguish "Idle — no integration" (eligible? false) from
"Off" (enabled? false) as genuinely different states.

### The one sanctioned exception: SES turning `email_ses_events` ON

The split above says an eligibility flag must not move with the
operator's toggle. `SQSPollingManager.enable_polling/0` moves one
anyway: it writes `email_ses_events` to `true` alongside
`sqs_polling_enabled`. That is a deliberate exception, not a leak of
the old conflation, and it is one-directional.

The reason is a dead end an operator could not see out of. Both flags
are needed before SES events arrive, but they live in two different
settings sections; an install where `email_ses_events` had been
switched off answered a freshly flipped Tracking toggle with "Idle —
no integration" and no hint that the missing piece was a checkbox on
another page. Enabling states an intent about the *install* ("SES
event tracking is a thing here"), which the eligibility flag is
exactly the right place to record — so enabling asserts it rather
than demanding the operator find it.

`disable_polling/0` does NOT clear the flag, and must not start. Three
reasons: "stop polling for now" is a statement about the operator, not
about the install, so the eligibility answer is unchanged; the same
flag gates the SNS webhook path (`Emails.Web.WebhookController`),
which does not poll anything and would go silent for no stated reason;
and clearing it would erase the difference between an install that
never tracked SES and one that paused. The flag keeps its own toggle
on the Email Tracking page (`Web.EmailTracking`, not the settings
section of the same name) for an operator who does mean
"no SES events at all".

The narrow shape of the exception is what keeps `state/1` honest: an
eligibility flag may be asserted by an operator action that logically
implies it, never retracted by one that does not.

# `state`

```elixir
@type state() :: :active | :idle_no_integration | :off | :stalled
```

The admin panel's four-word state vocabulary (spec §5).

# `t`

```elixir
@type t() :: module()
```

A tracker module implementing this behaviour.

# `accounts`
*optional* 

```elixir
@callback accounts() :: [{uuid :: String.t(), name :: String.t(), polled? :: boolean()}]
```

Per-integration opt-out list for the admin panel's Accounts column:
`{uuid, name, polled?}` for every currently-active account. **Optional**
— only define this if the provider has a genuine multi-account opt-out
concept (Brevo does; SES doesn't). A tracker that skips it gets `nil`
(see `accounts/1`), which the panel reads as "not applicable" and
renders as a plain placeholder in the Accounts cell rather than a
checkbox list.

# `eligible?`

```elixir
@callback eligible?() :: boolean()
```

Is there a working event source this tracker can poll right now? Folds
every feature/source gate (integration presence, feature flags) —
everything EXCEPT the operator's polling on/off switch.

# `enabled?`

```elixir
@callback enabled?() :: boolean()
```

The operator's polling master toggle only (not eligibility).

# `integration_count`
*optional* 

```elixir
@callback integration_count() :: non_neg_integer()
```

Admin panel's Integration-column count ("2 active integrations").
**Optional** — trackers without a working default (there isn't one:
every tracker has *some* meaningful count) may still skip it; a
tracker that doesn't define this gets `1`/`0` from `eligible?/0`
instead (see `integration_count/1`). Defining it explicitly is
strongly recommended whenever "how many" means something more precise
than a bare yes/no (Brevo's multi-account case).

# `interval_ms`

```elixir
@callback interval_ms() :: pos_integer()
```

Cadence (ms) the next self-schedule should use — the tracker's own configured interval.

# `label`

```elixir
@callback label() :: String.t()
```

Human-readable name for the admin panel ("Amazon SES", "Brevo").

# `last_polled_at`
*optional* 

```elixir
@callback last_polled_at() :: DateTime.t() | nil
```

This tracker's own durable "last completed cycle" timestamp, when it
keeps one. **Optional** — a tracker that skips it falls back to the
generic Oban-history derivation (see `last_polled_at/1`), which is
correct but only as long as the completed job still exists. Define
this whenever the tracker's polling interval can outlive
`Oban.Plugins.Pruner`'s `max_age` (Brevo's floor alone is 30s against
a 60s default), or the panel's Last-poll column reads "Never polled
yet" for a perfectly healthy chain.

# `min_interval_ms`

```elixir
@callback min_interval_ms() :: pos_integer()
```

The floor `set_polling_interval/1` (the informal Manager API — see
`EventTrackerRegistry`) enforces for this tracker, e.g. SES's `1_000`
vs Brevo's `30_000` (a lower bound driven by the provider's own API,
not a policy choice this behaviour makes). Not optional — every
tracker's interval editor needs *some* real floor to show as the
input's client-side `min`; a wrong/missing one either lets an operator
type a value the server will reject anyway, or falsely floors a
provider that could safely poll faster (dual-review P2 fix 2/5).

# `poll_cycle`

```elixir
@callback poll_cycle(context :: map()) :: :ok | {:error, term()}
```

Run one poll cycle synchronously, outside of Oban's own scheduling —
for callers that want an immediate, in-process cycle (e.g. a future
admin panel "Poll now" action) rather than queueing a job. Delegates to
the existing `*PollingJob.perform/1` logic; the self-scheduling job/
worker itself is unchanged by this behaviour.

# `provider_kind`

```elixir
@callback provider_kind() :: String.t()
```

The discriminator — matches `SendProfile.provider_kind` (`"aws_ses"`, `"brevo_api"`, …).

# `settings_component`
*optional* 

```elixir
@callback settings_component() :: module() | nil
```

The `Phoenix.LiveComponent` rendering this tracker's own provider-specific
settings, shown inside its expanded row in the "Delivery event tracking"
panel. **Optional** — a tracker that skips it (or returns `nil`) gets a
plain "no separate settings" note instead (see `settings_component/1`).

Only the module is returned, never `{module, assigns}`: the panel has no
provider-specific data to hand down, and the component is required to load
its own state in `update/2` — a caller-supplied assigns map would be a
second source of truth for the same settings, and the panel would have to
know what every provider needs in order to build it.

# `toggle_account_polling`
*optional* 

```elixir
@callback toggle_account_polling(uuid :: String.t()) :: {:ok, term()} | {:error, term()}
```

Flips one integration's polling opt-out (see `accounts/0`). **Optional**
— a tracker that skips it (because it skipped `accounts/0` too) gets a
safe no-op (see `toggle_account_polling/2`) instead of an
`UndefinedFunctionError` from a stale/forged panel action.

# `worker`

```elixir
@callback worker() :: module()
```

The Oban worker module backing this tracker's self-scheduling chain.

# `accounts`

```elixir
@spec accounts(t()) :: [{String.t(), String.t(), boolean()}] | nil
```

`accounts/0` if the tracker defines it, otherwise `nil` ("not
applicable" — no per-integration opt-out concept for this provider).

# `integration_count`

```elixir
@spec integration_count(t()) :: non_neg_integer()
```

`integration_count/0` if the tracker defines it, otherwise a safe
default derived from `eligible?/0` (`1` when eligible, `0` when not) —
the single guarded call site the admin panel uses, so a tracker that
skips the optional callback can never crash the panel (spec #56 P2
review: this used to be called unconditionally from the panel itself).

# `last_polled_at`

```elixir
@spec last_polled_at(t()) :: DateTime.t() | nil
```

Timestamp this tracker's chain last finished a cycle: the tracker's own
`last_polled_at/0` when it defines that optional callback, otherwise
derived generically from Oban's own job history (the last `completed`
job for `worker/0`), which needs zero extra plumbing per tracker. A
no-op cycle (nothing to fetch) still completes normally either way, so
this reads as "the chain is alive and ticking", matching what the
per-provider `*_last_polled_at` settings already intended.

> #### The Oban-history fallback is bounded by the Pruner {: .warning}
>
> `Oban.Plugins.Pruner` deletes `completed` rows older than its
> `max_age` (60s by default), so the fallback can only ever see a
> cycle that finished inside that window — a tracker polling less
> often than the Pruner keeps history reads as `nil` ("Never polled
> yet") even while perfectly healthy. That is exactly why
> `last_polled_at/0` exists as a callback: SES's default 5s cadence
> is comfortably inside any sane prune window, Brevo's 30s floor
> (and realistically much longer) is not, so `BrevoPollingManager`
> defines it against the durable `brevo_last_polled_at` setting its
> job already writes every cycle.

# `pending_jobs_count`

```elixir
@spec pending_jobs_count(t()) :: non_neg_integer()
```

Oban job count for this tracker's `worker/0`, across exactly
`available|scheduled|executing` — the "is a chain alive" health check
(spec §5's "Queued" column and `state/1`'s `:stalled` detection both
use this; **never** queued-only, see `state/1`'s moduledoc).

# `settings_component`

```elixir
@spec settings_component(t()) :: module() | nil
```

`settings_component/0` if the tracker defines it, otherwise `nil` — the
single guarded call site the panel uses, so a provider with no settings
of its own (and a Mailgun implementation that never heard of this
callback) renders the "no separate settings" note rather than crashing
the panel with an `UndefinedFunctionError`.

# `should_run?`

```elixir
@spec should_run?(t()) :: boolean()
```

`eligible?() and enabled?()` — the single condition the reconciler
enforces "exactly one chain" against. Not a callback (every tracker
gets this for free from the two it does implement) — takes the tracker
MODULE, not an instance, since trackers are stateless behaviours.

# `state`

```elixir
@spec state(t()) :: state()
```

Derives the admin panel's State column — exactly one of `:active`,
`:idle_no_integration`, `:off`, `:stalled` (spec §5).

`:stalled` (`should_run?` true, zero jobs across
`available|scheduled|executing`) is the one state that needs a false-
positive guard: a healthy chain inserts its own successor
*synchronously inside `perform`*, before returning, so at every point
in a normal cycle at least one row is `executing` (the current job,
which hasn't finished yet) or `scheduled`/`available` (its
already-inserted successor) — never both empty at once. Counting
`executing` (via `pending_jobs_count/1`) is what closes that window;
without it, reading queued-only states would flicker `:stalled` on
every single cycle. A transient miss beyond that (e.g. reconcile
hasn't run yet since a `SendProfile` was just added — no PubSub for
that today, spec §4.3) self-clears within one reconcile Cron tick;
this function makes no attempt to mask that window client-side, since
doing so would also hide a genuinely stalled chain.

# `toggle_account_polling`

```elixir
@spec toggle_account_polling(t(), String.t()) :: {:ok, term()} | {:error, term()}
```

`toggle_account_polling/1` if the tracker defines it, otherwise a
no-op success — guards against a stale or forged panel action
targeting a tracker with no opt-out concept (e.g. SES) ever reaching
an undefined function and crashing the LiveView.

---

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