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 itcaptured — never left the process: no host configured, or a test / logger delivery methodfailed — delivery was attempted and failed; the error is on the detail pageA 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 listsubject : String — encoded automatically for non-ASCIIhtml : String? — skip the view render and use this bodytext : String? — plain-text alternative partcc, bcc : String | Array? — bcc stays out of the headersreply_to : String?sender : String? — overrides the configured default Fromattachments : Array? — { "filename", "content_type", "content" } hashestemplate : String? — override the convention view pathAddresses 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 failingtest — capture mail in memory for assertions; never sendslogger — print a one-line summary instead of sendingEnvironment variables
SOLI_MAIL_DELIVERY_METHOD — delivery methodSOLI_SMTP_HOST / SOLI_SMTP_PORTSOLI_SMTP_USER / SOLI_SMTP_PASSSOLI_SMTP_TLS — tls modeSOLI_SMTP_FROM — default FromSOLI_SMTP_DOMAIN — EHLO name / Message-ID hostTesting
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)
})
})