ESC
Type to search...
S
Soli Docs

Mailer

Send transactional email over SMTP. Define a mailer like a controller — one method per email — render a view, and deliver now or in the background.

Mailers live in app/mailers/ and subclass Mailer. Each action sets instance variables, renders a matching .slv view by convention, and returns a Message you send with deliver_now or deliver_later. SMTP supports STARTTLS (587) and implicit TLS (465).

Defining a mailer

Each public method is an action that builds a message with this.mail(...). Instance variables set in the action are available to its view.

class UserMailer < Mailer
  def welcome(user)
    @user = user
    # Renders app/views/user_mailer/welcome.html.slv with @user in scope.
    this.mail(to: user.email, subject: "Welcome!")
  end

  def reset_password(user)
    @user = user
    this.mail(to: user.email, subject: "Reset your password")
  end
end

Scaffold one with the generator:

soli generate mailer User welcome reset_password

This writes the mailer class plus an HTML view per action under app/views/user_mailer/.

Views

A mailer view is a normal template; the action's instance variables are available as locals, exactly like a controller view. UserMailer#welcome renders user_mailer/welcome by convention.

<h1>Welcome, <%= h(user.name) %>!</h1>
<p>Thanks for joining. <a href="https://example.com/start">Get started</a>.</p>

Override the path with template:, or skip rendering by passing html: (and/or text:) straight to mail.

Plain-text part: add a welcome.text.slv next to welcome.html.slv and it's rendered automatically as the text/plain alternative.

Multiple arguments

An action can take more than one argument:

class OrderMailer < Mailer
  def receipt(order, invoice)
    @order = order
    this.mail(to: order.email, subject: "Receipt #{invoice.number}")
  end
end

OrderMailer.receipt(order, invoice).deliver_later

Omitted arguments arrive as nil (default parameter values are not applied), so pass every argument explicitly or fold them into a single hash.

Previewing (dev)

With soli serve --dev, browse every mailer view at /__soli/mailers — a gallery that renders each <mailer>/<action> HTML body in an iframe, so you can iterate on an email without sending one. Each entry links to /__soli/mailers/<mailer>/<action> for a full-page preview.

The preview renders the view directly (not the action), so give it example data with a leading <%# preview: {json} %> header — the same convention as the component catalog:

<%# preview: { "user": { "name": "Ada Lovelace" } } %>
<h1>Welcome, <%= h(user.name) %>!</h1>

Previews render the HTML part only (no layout) with built-in helpers plus the preview data; the action's real instance variables and request context aren't available. The gallery is dev-only — the routes don't exist in production.

For mail the app actually sent — real data, real recipients — see the dev inbox.

The dev inbox (dev)

/__soli/mailers previews templates with fake data. /__soli/inbox shows what the app actually sent — every message delivered since the dev server started, with the real data that was rendered into it.

soli serve . --dev     # then open http://localhost:3000/__soli/inbox

The tools button in the dev bar links to it — with a count of what's waiting — alongside the other dev galleries.

Each message opens on a detail page with its headers, its attachments, and three tabs: the HTML body (in a sandboxed iframe, so a mail's own scripts never run), the text part, and the raw RFC 5322 source — downloadable as .eml to open in a real mail client.

The listing is searchable and paginated. The search box matches the subject, any address (to, cc, bcc, from, reply-to), both bodies, and attachment filenames — ?q= and ?per= are plain query parameters, so a filtered view is linkable. Clear inbox empties it.

No SMTP server needed. A dev box rarely runs one, so under --dev a mailer with no configured host doesn't fail: the message lands in the inbox and the request carries on, the way letter_opener works in Rails.

Status of a captured message

sent — an SMTP server accepted it
captured — never left the process: no host configured, or a test / logger delivery method
failed — delivery was attempted and failed; the error is on the detail page

A failed message is captured too, which is the point: a rejected recipient or a refused connection is exactly what you opened the inbox to look at.

The inbox is dev-only and in-memory — it holds the last 100 messages and empties on restart. Outside --dev nothing is captured, the routes don't exist, and an unconfigured SMTP host is still a hard error.

Sending

Every action returns a Message. Send it synchronously, or enqueue it on the Job queue for background delivery.

# Send synchronously, within the current request
UserMailer.welcome(user).deliver_now

# Enqueue for background delivery; returns immediately
UserMailer.welcome(user).deliver_later

deliver_later enqueues a __MailDelivery job; if the queue is unavailable it logs and falls back to sending synchronously so a message is never dropped.

this.mail(...)

to : String | Array — one address or a list
subject : String — encoded automatically for non-ASCII
html : String? — skip the view render and use this body
text : String? — plain-text alternative part
cc, bcc : String | Array? — bcc stays out of the headers
reply_to : String?
sender : String? — overrides the configured default From
attachments : Array? — { "filename", "content_type", "content" } hashes
template : String? — override the convention view path

Addresses accept a bare addr@host or a "Name <addr@host>" display form — for to and cc as well as from and reply_to. A named recipient lands as one address, To: "Ana Vieira" <ana@example.com>.

Mailer.deliver(mail) — the hash, directly

this.mail(...) renders a mailer action into a hash and Message sends it. Anything that already has the hash can hand it over itself, and two keys live only on this path.

Mailer.deliver({
  "from":    "Acme <noreply@example.com>",
  "to":      "Ana Vieira <ana@example.com>",
  "subject": "Re: le point de mardi",
  "text":    Markdown.to_text(source),
  "html":    Markdown.to_html(source),
  "alternatives": [ { "content_type": "text/markdown", "body": source } ],
  "headers": { "In-Reply-To": "<one@mail>", "References": "<one@mail>" }
})

Extra headers

headers is a hash of header name to value, written verbatim. It exists for In-Reply-To and References — what makes an answer an answer rather than a new message in the reader it lands in — but any header name works.

Both halves of the line come from the application here, so both are guarded: a name or a value carrying CR or LF is header injection and is refused, as is a name containing : or whitespace.

Three faces of one message

text and html are the two alternatives every mail library knows, and they cover every message there is until one carries a third: the source a person actually typed. alternatives is an array of { "content_type", "body" } hashes for exactly that, text/markdown being what it exists for.

The parts are ordered least rich to most — plain, then the extras in the order given, then HTML — because a reader shows the last part it understands (RFC 2046 §5.1.4). A client that prefers the source can find it in the message's parts; every other client shows the HTML or the plain text and never knows it was there. Attachments still ride alongside: the message becomes multipart/mixed wrapping the multipart/alternative.

Markdown.to_text(md) is what the text face is built from — it keeps the bullets and the link addresses that stripping tags off the HTML would lose.

Attachments

Pass an attachments array to mail, or chain attach / attach_base64 on the returned Message. Text content goes verbatim; binary content is supplied as a base64 string.

UserMailer.welcome(user)
  .attach("notes.txt", "Thanks for signing up!")
  .attach_base64("logo.png", Base64.encode(File.read("logo.png")), "image/png")
  .deliver_later

Each attachment is { "filename", "content_type", "content" } (text) or { "filename", "content_type", "base64" } (binary).

Configuration

Configure delivery once in config/application.sl:

Mailer.configure({
  "delivery_method": "smtp",       # "smtp" | "test" | "logger"
  "host": getenv("SMTP_HOST"),
  "port": 587,                     # 465 = implicit TLS, 587 = STARTTLS
  "user": getenv("SMTP_USER"),
  "pass": getenv("SMTP_PASS"),
  "tls": "auto",                   # "auto" | "starttls" | "tls" | "none"
  "from": "Acme <noreply@example.com>"
})

tls: "auto" uses implicit TLS on port 465 and STARTTLS everywhere else. AUTH LOGIN is used when user/pass are set.

Delivery methods

smtp — send over SMTP (default); under --dev with no host, the mail is captured in the dev inbox instead of failing
test — capture mail in memory for assertions; never sends
logger — print a one-line summary instead of sending

Environment variables

SOLI_MAIL_DELIVERY_METHOD — delivery method
SOLI_SMTP_HOST / SOLI_SMTP_PORT
SOLI_SMTP_USER / SOLI_SMTP_PASS
SOLI_SMTP_TLS — tls mode
SOLI_SMTP_FROM — default From
SOLI_SMTP_DOMAIN — EHLO name / Message-ID host

Testing

Set delivery_method: "test" and assert on Mailer.deliveries() — the rendered mail hashes, newest last.

describe("UserMailer", fn() {
  before_each(fn() {
    Mailer.configure({ "delivery_method": "test" })
    Mailer.clear_deliveries()
  })

  test("welcome email", fn() {
    UserMailer.welcome(user).deliver_now()
    let sent = Mailer.deliveries()
    assert_eq(len(sent), 1)
    assert_eq(sent[0]["to"], user.email)
  })
})