PhoenixKit.Modules.Emails.EventTracker behaviour (phoenix_kit_emails v0.2.1)

Copy Markdown View Source

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.

Summary

Types

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

t()

A tracker module implementing this behaviour.

Callbacks

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.

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.

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

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).

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

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

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.

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).

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.

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

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.

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

Functions

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

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).

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.

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).

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.

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

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.

Types

state()

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

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

t()

@type t() :: module()

A tracker module implementing this behaviour.

Callbacks

accounts()

(optional)
@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?()

@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?()

@callback enabled?() :: boolean()

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

integration_count()

(optional)
@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()

@callback interval_ms() :: pos_integer()

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

label()

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

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

last_polled_at()

(optional)
@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()

@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(context)

@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()

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

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

toggle_account_polling(uuid)

(optional)
@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()

@callback worker() :: module()

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

Functions

accounts(tracker)

@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(tracker)

@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(tracker)

@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

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(tracker)

@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).

should_run?(tracker)

@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(tracker)

@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(tracker, uuid)

@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.