The welcome email: sent once, after a user confirms their address.
Off by default — the email_welcome_enabled setting, switched on under
Settings → Emails Transactional → Branding. Its copy is core's welcome
email (PhoenixKit.Email.CoreTemplates.welcome_defaults/0), overridable
with files like any other.
When it is sent
after_confirmation/1 is called where a user's own action confirms their
address:
Auth.confirm_user/1— the link in the confirmation email;Auth.confirm_user_from_external_proof/1— signing in with a magic link, or with an OAuth provider that verified the address, while the account is unconfirmed (a new OAuth account included);Auth.update_user_email/2— an unconfirmed account that corrects its address ("Wrong email?") and so confirms the new one;- magic-link registration, which confirms the new account itself.
An administrator confirming an account by hand (Auth.toggle_user_confirmation/2,
Auth.admin_confirm_user/1) sends nothing: the reader did not do anything,
and an operator confirming imported accounts must not mail every one of
them. admin_confirm_user/1 stays silent for the same reason — magic-link
registration calls this module itself, after it.
Only a real transition
The transactional paths record, before they confirm, whether the row is
still unconfirmed as their transaction sees it (track_transition/2, a
SELECT … FOR NO KEY UPDATE), and enqueue only then (multi/1). A confirmation
link clicked after an administrator already confirmed the account, or a
second tab holding a stale unconfirmed struct, confirms nothing new and
enqueues nothing. Magic-link registration confirms an account it has just
created, so its transition is real by construction.
After the confirmation commits
after_confirmation/1 only enqueues a job
(PhoenixKit.Users.WelcomeEmailWorker, queue notifications). The
confirmation link, the external-proof confirmation and the address
correction enqueue it as a step of the transaction that confirms the
address (multi/1), so the job exists exactly when the confirmation does:
a rollback — of the confirmation, or of a caller's transaction around it,
such as the OAuth callback's — takes the job with it. Magic-link
registration enqueues it right after admin_confirm_user/1 has committed,
outside any transaction. Nothing is claimed and nothing is sent while a
transaction is open, and no mail server ever holds a row lock — except
under Oban's testing: :inline, which runs a job the moment it is
inserted (a host's test setup, never production).
Once — at most
One job is enqueued per real transition, and before sending it claims
the account with one conditional UPDATE that records
welcome_email_sent_at in its custom_fields only if it is not there
yet. A confirmation repeated later (an address unconfirmed and confirmed
again) finds the mark and sends nothing. The mark is cleared for a retry
only when the email is known not to have gone out; see
PhoenixKit.Users.WelcomeEmailWorker for why a failure during delivery,
or a node dying between the claim and the send, loses the email instead
of risking a second one.
Never in the way
Enqueueing the welcome email never fails a confirmation that would
otherwise succeed. A node without Oban running logs that the job could not
be enqueued. Inside a transaction the insert runs under its own savepoint,
with a two-second lock_timeout: if it fails — refused, or kept
waiting on a locked oban_jobs — the error is logged, only the savepoint
is rolled back, and the confirmation commits without a job. (A connection
lost in the middle of the transaction fails the confirmation either way.)
Summary
Functions
Enqueues the welcome email for a user who has just confirmed their address, unless it is switched off.
Whether the welcome email is switched on. Off unless set.
after_confirmation/1 as a step of multi, after the step that confirms
the user (:user) — so the job is inserted in the confirmation's own
transaction — when track_transition/2 saw the row unconfirmed. Without
that step nothing is enqueued.
The custom_fields key that records when the welcome email was sent.
The setting that switches the welcome email on: "true" or "false".
Records, as a step of multi placed before the step that confirms
user, whether the user's row is still unconfirmed — locked
(FOR NO KEY UPDATE: it serialises confirmations of the row without
blocking foreign-key checks that only share its key), so of two
transactions confirming the same row only the first sees the transition.
multi/1 reads it.
Functions
@spec after_confirmation(PhoenixKit.Users.Auth.User.t() | term()) :: :enqueued | :skipped | :error
Enqueues the welcome email for a user who has just confirmed their address, unless it is switched off.
Returns :enqueued, :skipped (off, not confirmed) or :error (logged).
@spec enabled?() :: boolean()
Whether the welcome email is switched on. Off unless set.
@spec multi(Ecto.Multi.t()) :: Ecto.Multi.t()
after_confirmation/1 as a step of multi, after the step that confirms
the user (:user) — so the job is inserted in the confirmation's own
transaction — when track_transition/2 saw the row unconfirmed. Without
that step nothing is enqueued.
@spec sent_key() :: String.t()
The custom_fields key that records when the welcome email was sent.
@spec setting_key() :: String.t()
The setting that switches the welcome email on: "true" or "false".
@spec track_transition(Ecto.Multi.t(), PhoenixKit.Users.Auth.User.t()) :: Ecto.Multi.t()
Records, as a step of multi placed before the step that confirms
user, whether the user's row is still unconfirmed — locked
(FOR NO KEY UPDATE: it serialises confirmations of the row without
blocking foreign-key checks that only share its key), so of two
transactions confirming the same row only the first sees the transition.
multi/1 reads it.