What you can do with mailglass

Copy Markdown View Source

Current as of 2026-05-23. This guide covers the shipped, v1.x-stable jobs in mailglass and mailglass_admin. Inbound mail (mailglass_inbound) is summarized near the end, but it remains outside the v1.x stability promise for now.

Most docs answer "how does this feature work?" This one answers "what job am I hiring this library to do in my SaaS?" Read it once straight through if you're evaluating mailglass. After that, jump to the job you need.

The shortest useful mental model

If you only remember one sentence, make it this:

mailglass is the framework layer around transactional email that Phoenix teams usually end up rebuilding by hand.

Here is the lifecycle the library is built around:

  1. You define a Mailable.
  2. mailglass renders it into a Message.
  3. Sending creates a Delivery.
  4. Providers later report Events about that delivery.
  5. Those events can project Suppressions that block future sends.
  6. If you also receive mail, inbound arrives as an InboundMessage and is routed to a Mailbox.

One distinction is load-bearing:

dispatch ≠ delivered

  • Dispatched means mailglass handed the message to your provider.
  • Delivered means the recipient's mail server accepted it later, usually via webhook evidence.

That distinction shows up everywhere in the library because it is the difference between "we tried" and "the downstream system accepted it."

Where mailglass sits in your stack

Imagine a normal SaaS week:

  • You add a welcome email.
  • You want to preview it before deploy.
  • You queue sends so a provider hiccup does not slow the request.
  • Support asks whether a receipt really went out.
  • A bounce or complaint should stop future sends automatically.
  • One tenant wants Postmark, another wants SES.

Without mailglass, that becomes template plumbing, preview tooling, webhook normalization, suppression rules, signed unsubscribe links, and some kind of audit trail. With mailglass, those are the default path. You still choose your transport through Swoosh; mailglass does not replace it.

The jobs

#When you need to…Go to
1Build an email that renders everywhere, including OutlookJob 1
2See an email before it shipsJob 2
3Ship an auth email you can trustJob 3
4Send reliably in the backgroundJob 4
5Prove what actually happened to a messageJob 5
6Test your email without sending real mailJob 6
7Stop emailing addresses that bounce or complainJob 7
8Turn provider webhooks into one event streamJob 8
9Figure out why a delivery failed in productionJob 9
10Run all of this in a multi-tenant SaaSJob 10

Job 1: Build an email that renders everywhere

Scenario: you need a welcome email, a receipt, or an account alert that looks professional in Gmail, Apple Mail, and the one client everyone secretly fears: Outlook.

Mailglass.Components gives you HEEx-native building blocks that emit the table-heavy, MSO/VML-backed HTML email clients still require. You write intent; the library handles the hostile rendering environment.

defmodule MyApp.MailTemplates do
  use Phoenix.Component
  import Mailglass.Components

  def welcome(assigns) do
    ~H"""
    <.container>
      <.section>
        <.heading level={1}>Welcome</.heading>
        <.text>Hello <%= @name %>, your account is ready.</.text>
        <.button href="https://example.com/login">Sign in</.button>
      </.section>
    </.container>
    """
  end
end

What this job really buys you: no Node toolchain, no handwritten Outlook conditionals, no splitting your mental model between Phoenix templates and "the special email renderer."

Go deeper → Components


Job 2: See an email before it ships

Scenario: product wants a quick tweak, you changed copy and spacing, and you need to see the real output before a single customer gets it.

Mount the preview behind dev routes. It discovers your Mailables and runs them through the same renderer used for delivery.

defmodule MyAppWeb.Router do
  use Phoenix.Router
  import MailglassAdmin.Router

  if Application.compile_env(:my_app, :dev_routes) do
    scope "/dev" do
      pipe_through :browser
      mailglass_admin_routes "/mail"
    end
  end
end

Open http://localhost:4000/dev/mail and you get a sidebar of Mailables, HTML/Text/Raw/Headers tabs, device-width and dark-mode toggles, plus editable assigns.

What this job really buys you: preview is not a toy renderer. It is the production renderer with a UI around it, which means less drift and fewer "looked fine in preview, broke in inbox" surprises.

Go deeper → Preview


Job 3: Ship an auth email you can trust

Scenario: password resets and magic links are not "just another email." If a tracking pixel leaks data or a rewritten link changes the security posture, you have built a problem, not a feature.

Define the message on the :transactional stream and send it through the normal surface.

defmodule MyApp.UserMailer do
  use Mailglass.Mailable, stream: :transactional

  def password_reset(user, url) do
    new()
    |> to(user.email)
    |> from({"MyApp", "support@example.com"})
    |> subject("Reset your password")
    |> html_body("<p>Reset it here: #{url}</p>")
    |> text_body("Reset it here: #{url}")
    |> Mailglass.Message.put_function(:password_reset)
  end
end

{:ok, _delivery} =
  %{email: "alice@example.com"}
  |> MyApp.UserMailer.password_reset("https://example.com/reset/abc")
  |> Mailglass.deliver()

What this job really buys you: auth mail is safe by default. Open/click tracking stays off, and the NoTrackingOnAuthStream Credo check turns unsafe tracking on auth-shaped functions into a compile-time failure.

Go deeper → Getting Started · Authoring Mailables


Job 4: Send reliably in the background

Scenario: you want the request to finish fast, but you also want retries and replays to be boring instead of terrifying.

Use deliver_later/2. When Oban is available, mailglass uses it. When it is not, mailglass degrades to a supervised Task and tells you so once at boot.

%{email: "alice@example.com"}
|> MyApp.UserMailer.welcome()
|> Mailglass.deliver_later()

Every logical send carries an idempotency key, so retried work converges instead of duplicating mail.

What this job really buys you: the common async path without making Oban a hard requirement, plus replay safety enforced in persistence rather than hoped for in application code.

Go deeper → Testing · Authoring Mailables


Job 5: Prove what actually happened to a message

Scenario: support asks, "Did the receipt go out?" What they mean is usually three different questions: was it rendered, was it handed to the provider, and was it accepted downstream?

mailglass records those facts in an append-only event ledger and broadcasts status changes over PubSub.

Phoenix.PubSub.subscribe(
  Mailglass.PubSub,
  Mailglass.PubSub.Topics.events(tenant_id, delivery.id)
)

def handle_info({:delivery_updated, _delivery_id, status, _meta}, socket) do
  # status flows :queued -> :dispatched -> :delivered (or :bounced)
  {:noreply, assign(socket, :status, status)}
end

The mailglass_events table is append-only by construction. Updates and deletes raise SQLSTATE 45A01, which means the audit trail is protected from both bugs and convenience.

What this job really buys you: one durable place to ask what happened, with enough fidelity to answer "we sent it" separately from "the recipient side accepted it."

Go deeper → Telemetry


Job 6: Test your email without sending real mail

Scenario: you want fast tests that tell you what was sent, without provider credentials, flaky inboxes, or process-mailbox tricks that fall apart under async: true.

Point tests at Mailglass.Adapters.Fake and use the shipped assertions.

defmodule MyApp.UserMailerTest do
  use ExUnit.Case, async: true
  import Mailglass.TestAssertions

  test "delivers the welcome message" do
    %{email: "user@example.com"}
    |> MyApp.UserMailer.welcome()
    |> Mailglass.deliver()

    assert_mail_sent(subject: "Welcome", to: "user@example.com")
  end
end

For most app tests, use Mailglass.MailerCase is the easier baseline: it wires the Fake adapter, tenancy, and delivery-event subscription for you.

What this job really buys you: the test path is not second-class. The Fake adapter is the project's own release gate, so the tooling you rely on is held to the same standard as the library itself.

Go deeper → Testing


Job 7: Stop emailing addresses that bounce or complain

Scenario: a recipient hard-bounces or files a complaint. At that point the important question is no longer "can we send?" but "can we make sure we never do this again by accident?"

mailglass projects suppressions from verified webhook events and blocks future sends before they reach your provider.

case Mailglass.deliver(message) do
  {:ok, delivery} ->
    handle_sent(delivery)

  {:error, %Mailglass.SuppressedError{} = error} ->
    Logger.info("Delivery blocked: #{error.message}")
end

Hard bounces and complaints create standing suppressions. Unsubscribes create stream-aware suppressions. If you ever need to rebuild truth from the ledger, mix mailglass.suppressions.resync is the repair path.

What this job really buys you: compliance and deliverability policy is not a spreadsheet, not tribal knowledge, and not a best-effort callback you hope every team remembers to call.

Go deeper → Webhooks


Job 8: Turn provider webhooks into one event stream

Scenario: today's provider is Postmark, tomorrow's might be SES, and you do not want business logic that knows five webhook dialects.

Mount the webhook routes and let mailglass verify signatures and normalize events into one vocabulary.

defmodule MyAppWeb.Router do
  use Phoenix.Router
  import Mailglass.Webhook.Router

  scope "/" do
    pipe_through :api
    mailglass_webhook_routes "/webhooks", providers: [:postmark, :sendgrid]
  end
end

Postmark and SendGrid ship on the zero-arg mount. :mailgun, :ses, and :resend are explicit opt-ins. A forged signature raises Mailglass.SignatureError and stops there.

What this job really buys you: your app reasons about normalized email events, not provider payload shape. Provider swaps become infrastructure work, not domain rewrites.

Go deeper → Webhooks


Job 9: Figure out why a delivery failed in production

Scenario: a customer says "I never got it," and you need an answer that is better than searching logs and reconstructing the timeline in your head.

Mount the operator dashboard in your app, behind your auth.

scope "/ops" do
  pipe_through [:browser, :require_authenticated_user]

  mailglass_operator_routes "/mail",
    auth: MyApp.MailglassAdminAuth
end

The operator surface turns the event ledger into a timeline you can use: delivery history, exact stored webhook evidence, replay, and reconciliation of orphaned webhook races through mix mailglass.reconcile.

What this job really buys you: production debugging without turning email operations into a separate product or handing your delivery truth to a hosted third party.

Go deeper → Operator Incident Support


Job 10: Run all of this in a multi-tenant SaaS

Scenario: one workspace sends from billing@tenant-a.com, another from support@tenant-b.com, and leaking data across that line would be catastrophic.

mailglass treats multi-tenancy as a first-class domain concern. tenant_id is on every delivery, event, and suppression, and the Mailglass.Tenancy behaviour controls scoping plus optional per-tenant adapter routing.

defmodule MyApp.Tenancy do
  @behaviour Mailglass.Tenancy

  @impl Mailglass.Tenancy
  def scope(query, %{tenant_id: tenant_id}) do
    Mailglass.Tenancy.scope(query, %{tenant_id: tenant_id})
  end

  @impl Mailglass.Tenancy
  def resolve_outbound_adapter_ref(%{tenant_id: "acme"}), do: {:ok, :postmark_acme}
  def resolve_outbound_adapter_ref(_ctx), do: :default
end

If you do not need this, the single-tenant path is zero-config. If you do need it, the data model already expects it.

What this job really buys you: tenant isolation and provider routing as part of the framework contract, not an afterthought layered on later.

Go deeper → Multi-Tenancy


One more thing: receiving mail

If your SaaS also needs to receive email, mailglass_inbound is the sibling package for that job: inbound router DSL, Mailbox behaviour, verified ingress, replayable storage, and async execution.

Today it ships verified ingress for Postmark and SendGrid, and the repo's v1.2 work is expanding that surface with more provider, operator, testing, and docs maturity. It is real, useful, and shipping, but it is still outside the v1.x stability promise. Treat it as production-capable and still hardening.

What mailglass deliberately does not do

The edges matter because they keep the library coherent:

  • Marketing email is out. Campaigns, lists, segmentation, A/B tests, and drip automation belong to Keila or Listmonk.
  • Multi-channel notifications are out. SMS, push, and in-app point toward a different abstraction entirely.
  • A hosted ops console is out. mailglass_admin mounts inside your app.
  • A built-in subscriber preference center is out. Build it on top of suppression and consent primitives if your app needs it.

And one boundary is ideological as much as technical:

Open/click tracking is off by default and never allowed on auth mail.

That is not missing polish. It is the product stance.

The full rationale for those boundaries lives in .planning/PROJECT.md.


Last updated: 2026-05-23. Public JTBD projection refreshed from .planning/research/JTBD-COVERAGE.md.