Sending email, for every site through its own provider.
A consumer — a sign-in sender, a notification channel, an extension — builds a Swoosh.Email
(email/2 fills the site's sender) and calls deliver/2 with the site it is for. It never names a provider: each scope picks one on the settings page's Email tab
(mailer.provider), and providers are mishka_installer plugins (MishkaCmsCore.Mail.Provider),
the core's own Resend included.
No borrowing
Each scope sends with what it set itself: a site with no provider, or one missing its key, sends
nothing — not even through the installation's provider. nil as the site is the installation's
own scope, which sends a master's email.
The path of one email
"mail:deliver:before"— a plugin may change the email ({:reply, %{data | email: email}}) or stop it ({:reply, :halt, Map.put(data, :suppress, true)}). A test email passes here too.- A
MishkaCmsCore.Mail.Deliveryrow is written::queued, or:not_configuredwith the reason, and"mail:not_configured"fires. - A
MishkaCmsCore.Mail.Workers.DeliverWorkerjob on themailqueue sends it — inline underenv_test?. The job carries the email sealed (it may hold a sign-in link) and no key; inside the caller's transaction, a rolled-back registration takes its job with it. - The provider answers: the row goes
:sentand"mail:delivered"fires, or the attempt is retried while the provider says it may succeed, then the row goes:failedand"mail:failed"fires. Resend's idempotency key keeps a retry from sending twice.
The three notices are fired with return: false. Every job shows on the Activity page under its
site, and every row on the Email tab.
The sender
email/2 fills each scope's own mailer.from, mailer.from_name and mailer.reply_to
(resolve: :own). A site that set no address sends from noreply@ its own domain, never from
another site's; one that set no name sends under its title.
Mail.email(site_id, to: "ada@example.com", subject: "Reset your password", html: "<p>…</p>", text: "…")
|> Mail.deliver(site_id: site_id, purpose: "password_reset")History
A site's emails older than mailer.history_days are deleted when it next sends one — work done
when something changed, never on a timer.
Summary
Functions
Whether site_id can send email now.
The provider a scope uses until it picks one: "none" — no email — unless the deployment says
otherwise (config :mishka_cms_core, :mail_default_provider, the development mailbox in dev).
Hands email over for opts[:site_id] (nil for the installation). :purpose (required) says
what the email is for — "password_reset", "magic_link", "confirmation", or anything an
extension sends; :idempotency_key names this one email so a retry is never sent twice.
Why a send failed, in words an admin can act on: the provider's own message where it gave one, cut short. Never a key: providers' error bodies do not carry one, and nothing else is shown.
A new email from site_id (nil for the installation), with fields :to, :subject, :html
and :text, and the scope's own sender. A missing :text is left out, and the provider writes
one from the HTML.
The address site_id sends from: its own, or noreply@ its domain — the master site's for Global.
The name site_id sends under.
The provider site_id sends with, ready to send: {:ok, module}, or {:error, reason} —
:not_configured (none chosen), :unknown_provider (chosen, but its plugin is gone),
:cannot_run (chosen, while the providers' plugins cannot run) or
:not_ready (chosen, but missing what it needs, such as its key).
Deletes site_id's emails older than mailer.history_days — every site's for nil, the
installation's own sweep. Run when a site sends, by the job that sent it.
A purpose in words, for the history table.
Where replies to site_id's email go, or nil for the from address.
Sends a test email to to for site_id now, through its provider, and waits for the answer:
{:ok, message_id} or {:error, why} in words for the settings page. Recorded like any other.
The settings the Email tab shows: which provider a scope uses, then every offered provider's own,
each drawn only while its provider is the one chosen (MishkaCmsCore.Settings.shown_when/3).
Part of MishkaCmsCore.Settings' core definitions.
A delivery status in words.
Functions
@spec can( action_or_query_or_changeset :: Ash.Query.t() | Ash.Changeset.t() | {Ash.Resource.t(), atom() | Ash.Resource.Actions.action()}, actor :: term(), opts :: Keyword.t() ) :: {:ok, boolean() | :maybe} | {:ok, true, Ash.Changeset.t() | Ash.Query.t()} | {:ok, true, Ash.Changeset.t(), Ash.Query.t()} | {:ok, false, Exception.t()} | {:error, term()}
@spec can?( query_or_changeset_or_action :: Ash.Query.t() | Ash.Changeset.t() | {Ash.Resource.t(), atom() | Ash.Resource.Actions.action()}, actor :: term(), opts :: Keyword.t() ) :: boolean() | no_return()
Whether site_id can send email now.
@spec default_provider() :: String.t()
The provider a scope uses until it picks one: "none" — no email — unless the deployment says
otherwise (config :mishka_cms_core, :mail_default_provider, the development mailbox in dev).
@spec deliver(Swoosh.Email.t(), keyword()) :: {:ok, MishkaCmsCore.Mail.Delivery.t() | :suppressed} | {:error, term()}
Hands email over for opts[:site_id] (nil for the installation). :purpose (required) says
what the email is for — "password_reset", "magic_link", "confirmation", or anything an
extension sends; :idempotency_key names this one email so a retry is never sent twice.
Returns {:ok, delivery} once it is queued (or sent, under env_test?), {:ok, :suppressed}
when a plugin stopped it, {:error, :not_configured} when the scope has no ready provider, or
{:error, reason}. It does not raise: an email is never worth failing the action that sent it.
Why a send failed, in words an admin can act on: the provider's own message where it gave one, cut short. Never a key: providers' error bodies do not carry one, and nothing else is shown.
@spec email(String.t() | nil, keyword()) :: Swoosh.Email.t()
A new email from site_id (nil for the installation), with fields :to, :subject, :html
and :text, and the scope's own sender. A missing :text is left out, and the provider writes
one from the HTML.
The address site_id sends from: its own, or noreply@ its domain — the master site's for Global.
The name site_id sends under.
The provider site_id sends with, ready to send: {:ok, module}, or {:error, reason} —
:not_configured (none chosen), :unknown_provider (chosen, but its plugin is gone),
:cannot_run (chosen, while the providers' plugins cannot run) or
:not_ready (chosen, but missing what it needs, such as its key).
@spec prune(String.t() | nil) :: :ok
Deletes site_id's emails older than mailer.history_days — every site's for nil, the
installation's own sweep. Run when a site sends, by the job that sent it.
A purpose in words, for the history table.
Where replies to site_id's email go, or nil for the from address.
Sends a test email to to for site_id now, through its provider, and waits for the answer:
{:ok, message_id} or {:error, why} in words for the settings page. Recorded like any other.
@spec settings() :: [MishkaCmsCore.Settings.definition()]
The settings the Email tab shows: which provider a scope uses, then every offered provider's own,
each drawn only while its provider is the one chosen (MishkaCmsCore.Settings.shown_when/3).
Part of MishkaCmsCore.Settings' core definitions.
A delivery status in words.