PhoenixKit. Modules. Emails. EventTracker behaviour
(phoenix_kit_emails v0.4.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.
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.
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", …).
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).
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.
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.
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", …).
@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.
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).
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.
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.