# Swoosh Emails

> Use when sending emails from Phoenix applications. Invoke before implementing email functionality. Covers Swoosh setup, email templates, delivery configuration, testing, and production adapters. Trigger words: email, Swoosh, mailer, email templates, SMTP, SendGrid, email testing.

- Skill: `igmarin/swoosh-emails` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add igmarin/swoosh-emails`
- Raw SKILL.md: https://api.skillmd.com/api/skills/igmarin/swoosh-emails/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Productivity
- License: MIT
- Author: igmarin (https://skillmd.com/u/igmarin)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/igmarin/swoosh-emails

---


# Swoosh Emails


Canonical FP bar: [`docs/fcis-engineering-rules.md`](../../docs/fcis-engineering-rules.md) — **Functional Core, Imperative Shell**: pure domain modules; side effects at edges. HTTP/email/i18n adapters are edges; keep request building and response mapping pure where possible.

## RULES — Follow these with no exceptions

**1.** **Define emails in separate modules** — `MyApp.Emails.UserEmail`, not inline in contexts
**2.** **Use Phoenix components for email templates** — reuse UI components in emails
**3.** **Configure delivery per environment** — Local adapter in dev/test, real adapter in prod
**4.** **Test emails with Swoosh.TestAssertions** — assert emails were sent with correct content
**5.** **Never send emails synchronously in web requests** — use Oban for async delivery; Task.start only for simple cases


## Setup

```elixir
# mix.exs
defp deps do
  [
    {:swoosh, "~> 1.14"},
    {:finch, "~> 0.18"},
    {:gen_smtp, "~> 1.0"}
  ]
end

# application.ex
def start(_type, _args) do
  children = [
    # ...
    {Finch, name: MyApp.Finch}
  ]
  # ...
end
```

### Validate Setup

After adding deps and configuring the supervision tree, confirm everything works before writing email modules:

```elixir
# In iex -S mix (dev environment with Local adapter)
MyApp.Mailer.deliver(Swoosh.Email.new(to: "test@example.com", from: "noreply@myapp.com", subject: "Test"))
# => {:ok, %{}} — mailer configured correctly
# => {:error, ...} — Finch missing from supervision tree or adapter misconfigured
```


## Defining Emails

See [`assets/mailer_template.ex`](assets/mailer_template.ex) for a copy-paste template with the Mailer module, an email-builder module, and an Oban delivery worker.

### With Phoenix Components

```elixir
# lib/my_app/emails/user_email.ex
defmodule MyApp.Emails.UserEmail do
  import Swoosh.Email
  import Phoenix.Component, only: [sigil_H: 2]
  alias MyAppWeb.EmailComponents

  def welcome(user) do
    assigns = %{user: user}

    html =
      ~H"""
      <EmailComponents.layout>
        <h1>Welcome, <%= @user.name %>!</h1>
        <p>Thanks for signing up.</p>
        <EmailComponents.button href={url(~p"/dashboard")}>Get Started</EmailComponents.button>
      </EmailComponents.layout>
      """
      |> Phoenix.HTML.Safe.to_iodata()
      |> IO.iodata_to_binary()

    new()
    |> to({user.name, user.email})
    |> from({"MyApp", "noreply@myapp.com"})
    |> subject("Welcome to MyApp!")
    |> html_body(html)
    |> text_body("Welcome, #{user.name}! Thanks for signing up.")
  end
end
```

### Email Layout and Button Components

```elixir
# lib/my_app_web/components/email_components.ex
defmodule MyAppWeb.EmailComponents do
  use Phoenix.Component

  def layout(assigns) do
    ~H"""
    <html>
      <body style="font-family: sans-serif; max-width: 600px; margin: auto;">
        <%= render_slot(@inner_block) %>
      </body>
    </html>
    """
  end

  def button(assigns) do
    ~H"""
    <a href={@href} style="background: #4F46E5; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none;">
      <%= render_slot(@inner_block) %>
    </a>
    """
  end
end
```


## Mailer Module

```elixir
# lib/my_app/mailer.ex
defmodule MyApp.Mailer do
  use Swoosh.Mailer, otp_app: :my_app
end
```


## Configuration

```elixir
# config/dev.exs
config :my_app, MyApp.Mailer,
  adapter: Swoosh.Adapters.Local

config :swoosh, serve: true

# config/test.exs
config :my_app, MyApp.Mailer,
  adapter: Swoosh.Adapters.Test

# config/runtime.exs (production)
config :my_app, MyApp.Mailer,
  adapter: Swoosh.Adapters.Sendgrid,
  api_key: System.get_env("SENDGRID_API_KEY")
```


## Sending Emails

### With Oban

```elixir
# lib/my_app/workers/send_welcome_email.ex
defmodule MyApp.Workers.SendWelcomeEmail do
  use Oban.Worker, queue: :mailers, max_attempts: 3

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"user_id" => user_id}}) do
    case Accounts.get_user(user_id) do
      nil ->
        {:cancel, "user #{user_id} not found"}

      user ->
        email = MyApp.Emails.UserEmail.welcome(user)

        case MyApp.Mailer.deliver(email) do
          {:ok, _} -> {:ok, :sent}
          {:error, reason} -> {:error, reason}
        end
    end
  end
end

# Enqueue from context
def register_user(attrs) do
  with {:ok, user} <- create_user(attrs) do
    %{user_id: user.id}
    |> MyApp.Workers.SendWelcomeEmail.new()
    |> Oban.insert()

    {:ok, user}
  end
end
```

### With Task (Simple Cases Only)

```elixir
def register_user(attrs) do
  with {:ok, user} <- create_user(attrs) do
    Task.start(fn ->
      user |> UserEmail.welcome() |> Mailer.deliver()
    end)

    {:ok, user}
  end
end
```


## Testing Emails

```elixir
defmodule MyApp.AccountsTest do
  use MyApp.DataCase, async: true

  import Swoosh.TestAssertions

  test "sends welcome email on registration" do
    attrs = %{email: "test@example.com", password: "password123"}

    assert {:ok, user} = Accounts.register_user(attrs)

    assert_email_sent(fn email ->
      assert email.to == [{user.name, user.email}]
      assert email.subject =~ "Welcome"
    end)
  end

  test "no email sent on failed registration" do
    attrs = %{email: "", password: ""}

    assert {:error, _changeset} = Accounts.register_user(attrs)

    assert_no_email_sent()
  end
end
```


## Email Preview in Development

```elixir
# config/dev.exs
config :swoosh, serve: true

# Access preview at http://localhost:4000/dev/mailbox
```


## Common Pitfalls

| ❌ Don't | ✅ Do |
|----------|-------|
| Build emails inline inside a context | Define them in dedicated modules (`MyApp.Emails.UserEmail`) |
| Call `Mailer.deliver/1` inside a web request | Enqueue an Oban job (or `Task.start` for simple cases) so the request never blocks on SMTP |
| Ship an HTML-only email | Always set both `html_body/2` and `text_body/2` |
| Use a real adapter in dev/test | `Swoosh.Adapters.Local` in dev, `Swoosh.Adapters.Test` in test, real adapter only in prod |
| Hardcode the API key in config | Read it at runtime: `System.get_env("SENDGRID_API_KEY")` in `runtime.exs` |
| Assert delivery by inspecting logs | Use `Swoosh.TestAssertions` — `assert_email_sent/1` and `assert_no_email_sent/0` |
| Forget `{Finch, name: MyApp.Finch}` in the supervision tree | Start Finch so API-based adapters have an HTTP client |

---

## Integration

| Predecessor | This Skill | Successor |
|-------------|------------|-----------|
| phoenix-auth-customization | swoosh-emails | oban-essentials |
| ecto-essentials | swoosh-emails | testing-essentials |

**Companion skills:**
- `oban-essentials` — deliver emails asynchronously with retries
- `testing-essentials` — assert delivery with `Swoosh.TestAssertions`

