:render_body — mutates the calls that decide which body parts an email renders:
Phoenix.Swoosh.render_body/2,3, the call that renders an email's templates onto its
html_body/text_body fields, and the format map of Phoenix.Swoosh.put_new_formats/2,
which decides what "both parts" even means. Three kinds, variant-labelled:
Removal (label remove) — the call collapses to the email it received, so the email is
built, addressed, and delivered with no rendered body at all:
render_body(email, :welcome, %{name: name}) -> email
email |> render_body(:welcome) -> Elixir.Function.identity()A survivor means no test asserts the rendered body content — the mail pipeline is exercised,
but nothing checks that html_body/text_body ever got set.
Atom-template narrowing (labels html_only / text_only) — an atom template renders
both the .html and the .text template; the string form renders exactly one. Narrowing
the literal atom to each string form drops the other body part:
render_body(email, :welcome, assigns) -> render_body(email, "welcome.html", assigns)
-> render_body(email, "welcome.text", assigns)A survivor means no test asserts the other body part — the classic "the text part broke and
nobody noticed" gap. Swoosh.TestAssertions.assert_email_sent/1 on both bodies, or a direct
email.text_body =~ ..., kills it. Narrowing fires only on a literal atom template; a string
or computed template gets the removal mutant alone.
Format dropping (label format) — a literal put_new_formats/2 map with more than one
entry loses one entry per mutant, so that extension's template stops rendering:
put_new_formats(email, %{"html" => :html_body, "amp" => :html_body})
-> put_new_formats(email, %{"amp" => :html_body})
-> put_new_formats(email, %{"html" => :html_body})This is the custom-formats form of the same gap the narrowings probe — and the only form of
it once a mailer configures :formats, since the narrowings' .html/.text extensions may
not exist there (a narrowed mutant then dies as a missing-template crash: an uninformative
kill, never a wrong survivor). A single-entry map produces nothing: dropping the only format
renders no body at all, which is the remove mutant's diff, and no mutant has two homes.
Suppress one kind with # mutare:ignore[render_body:remove] / [render_body:html_only] /
[render_body:text_only] / [render_body:format], or the family with
# mutare:ignore[render_body].
Only the real effective arities fire — render_body/2,3 (/2 is the use-injected
wrapper's default-assigns form) and put_new_formats/2 — so a name-matched call of any other
arity is left alone, keeping every metamutant compiling. Piped calls are removed with the
Elixir.Function.identity() no-op stage (the :Elixir-led alias is never rewritten by alias
resolution, so the no-op always names the real Function.identity/1).
How the three kinds relate
At an atom-template site the removal mutant is the weakest of the three: any assertion that
kills a narrowing also kills the removal, so a surviving remove where both narrowings also
survive is one gap reported three times, not three gaps. It is kept because it is the only
mutant at a string or computed template site, and the only one that also drops the assigns
merge (a test that reads email.assigns rather than the bodies kills it alone).
The template and format positions are pinned
The template name (effective argument 1 — atom or string) is a structural identifier, not a
computed value: a perturbed name is a missing-template crash at render time (an
uninformative crash-kill), never a signal. put_new_formats/2's extension→field map is the
same kind of value — a perturbed extension key is a missing template, a perturbed field value
is a crash-free but meaningless field swap. This family registers all three calls in the
call-routing registry with those positions :raw, so no family — core value literals
included — mutates them or anything inside them, at bare, qualified, and aliased call sites
alike. The registry route (rather than an argument mark) is deliberate: it covers the
position's whole subtree, and registration is also what makes the bare forms resolvable and
witness-safe at all (see below). Keeping a position raw for everyone and then minting the one
safe mutation there from its owner is exactly what the routing contract is for.
Resolution: why this family is half of a matched pair
use Phoenix.Swoosh injects import Phoenix.Swoosh, except: [render_body: 3] plus a local
def render_body/2,3 wrapper — so a mailer's bare render_body calls resolve to a local
definition invisible to Mutare. Mutare.Phoenix.Swoosh (listed under :extensions)
overrides that use to surface a whole import Phoenix.Swoosh in its place; this family's
registry entries then complete the picture twice over:
- the
/2wrapper arity is not a realPhoenix.Swooshexport, so import reflection can never resolve a barerender_body(email, :welcome)— the registry's whole-import fallback is what resolves it; - a bare imported call normally carries a compile-time witness that re-imports the
believed provider next to the mutant — which, next to the real injected local
def, is an import/local conflict that would fail the single metamutant compile. Registered calls have that witness dropped.
Without the extension, bare render_body sites are neither mutated nor pinned (the
faithfully-harvested except: import excludes exactly this one name); qualified and aliased
sites work regardless.
Deliberately left alone
- The assigns argument is ordinary runtime data — core's families mutate the values
inside it as they would anywhere (a wrong interpolated value in a body only a
body-content assertion catches). Its keys are likewise left to core's uniform
map-literal judgment, not pinned here. The one assigns entry this package does speak for
is
:layout, which is phoenix_swoosh vocabulary rather than template data —Mutare.Phoenix.Swoosh.Layoutowns it. - Dropping an assigns entry was considered and rejected: a template referencing the
dropped assign raises
KeyError/ArgumentErrorat render — a crash-kill, not a survivor question. - Swapping a format's body field (
"html" => :html_body→:text_body) is not minted: the rendered html would land intext_bodyalongside whatever the text template put there, and which one survives depends on map ordering — a mutant whose behaviour a reader cannot predict from its diff.