PhoenixKit.Users.WelcomeEmail (phoenix_kit v2.55.1)

Copy Markdown View Source

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

after_confirmation(user)

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

enabled?()

@spec enabled?() :: boolean()

Whether the welcome email is switched on. Off unless set.

multi(multi)

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

sent_key()

@spec sent_key() :: String.t()

The custom_fields key that records when the welcome email was sent.

setting_key()

@spec setting_key() :: String.t()

The setting that switches the welcome email on: "true" or "false".

track_transition(multi, user)

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