PhoenixKit. Modules. Emails. EventTracker behaviour
(phoenix_kit_emails v0.2.0)
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 forworker/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).
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.
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
@type state() :: :active | :idle_no_integration | :off | :stalled
The admin panel's four-word state vocabulary (spec §5).
@type t() :: module()
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.
@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.
@callback enabled?() :: boolean()
The operator's polling master toggle only (not eligibility).
@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).
@callback interval_ms() :: pos_integer()
Cadence (ms) the next self-schedule should use — the tracker's own configured interval.
@callback label() :: String.t()
Human-readable name for the admin panel ("Amazon SES", "Brevo").
@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.
@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).
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.
@callback provider_kind() :: String.t()
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.
@callback worker() :: module()
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).
@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).
@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.
@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).
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).
: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/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.