Digging Deeper
Sending Email
This page shows you how to send email from a Wheels app. You’ll configure SMTP, organize your mailer code, send a welcome message after signup, ship multi-part HTML and plain text, attach files, enqueue the whole thing in a background job so signup returns fast, and point the same code at a transactional provider in production.
You’ll learn:
- How to configure SMTP defaults so every send shares the same server credentials
- How to organize send calls in
app/mailers/and call them from a controller - How to send in a background job so requests stay fast
- How to ship a multi-part HTML + plain text body
- How to attach files
- How to point dev at a local capture tool and prod at a real SMTP provider
The one function
Section titled “The one function”Wheels ships a single email function: sendEmail(), available on every controller. It renders a view template, composes a cfmail call, and delivers it through your configured SMTP server. Everything else — the mailer class convention, multi-part bodies, attachments, async sending — is a pattern around that one call.
component extends="Controller" { function create() { user = model("User").create(params.user); sendEmail( template="welcome", from="no-reply@example.com", to=user.email, subject="Welcome!", user=user ); redirectTo(route="login"); }}Custom keys (like user=user above) are forwarded to the view template as local variables — user is in scope inside app/views/controllername/welcome.cfm.
Configure SMTP defaults
Section titled “Configure SMTP defaults”You don’t want to repeat server, username, password on every sendEmail() call. Set per-function defaults in config/settings.cfm:
set(functionName="sendEmail", server="smtp.example.com", port=587, useTLS=true, username="smtp-user", password=env("SMTP_PASSWORD"));Every optional argument cfmail accepts is available here: server, port, username, password, useSSL, useTLS, replyto, failto, and more. Pulling secrets from env() keeps credentials out of source control.
Don’t set defaults for from, to, or subject, though — those three are required parameters of sendEmail(), and your CFML engine enforces them before Wheels gets a chance to apply configured defaults. A from default set here is never used; omitting from= at a call site throws a missing-parameter error on every engine even with the default configured. Always pass from= (and to=, subject=) explicitly.
Organize sends in app/mailers/
Section titled “Organize sends in app/mailers/”For anything beyond a one-liner, move the send call out of the controller and into a mailer component. Mailers are plain CFCs — no framework base class — that wrap sendEmail() behind a named method. Because sendEmail() is a controller function and needs a request-capable controller instance (one with a params struct), each mailer method obtains one through the controller() factory:
component { public any function sendWelcome(required any user) { local.mailer = new wheels.Global().controller( name="Mailer", params={controller: "mailer", action: "sendWelcome"} ); return local.mailer.sendEmail( template="/mailers/user/welcome", layout="/mailers/layout", from="no-reply@example.com", to=arguments.user.email, subject="Welcome, ##arguments.user.firstName##!", user=arguments.user ); }
public any function sendPasswordReset(required any user, required string token) { local.mailer = new wheels.Global().controller( name="Mailer", params={controller: "mailer", action: "sendPasswordReset"} ); return local.mailer.sendEmail( template="/mailers/user/password_reset", layout="/mailers/layout", from="no-reply@example.com", to=arguments.user.email, subject="Reset your password", user=arguments.user, token=arguments.token ); }}Put the views under app/views/mailers/user/welcome.cfm and app/views/mailers/user/password_reset.cfm. The leading slash on template= makes the path absolute (rooted at app/views/) so the mailer works regardless of which controller triggered it.
Call it from any controller:
component extends="Controller" { function create() { user = model("User").create(params.user); if (user.hasErrors()) { renderView(action="new"); return; } new app.mailers.UserMailer().sendWelcome(user); redirectTo(route="login", success="Welcome! Please log in."); }}Write the template
Section titled “Write the template”Templates are regular Wheels view files. Any custom key you pass to sendEmail() or the mailer method is available as a local variable:
<cfoutput><p>Hi #user.firstName#,</p><p>Welcome to the app. Log in any time at #linkTo(route='login', onlyPath=false)#.</p></cfoutput>Pass onlyPath=false to linkTo() inside email templates — a relative path like /login is useless in an inbox.
Send in a background job
Section titled “Send in a background job”sendEmail() runs synchronously: the template render, message composition, and spool/handoff all happen inside the request before the redirect fires. By default both Lucee and Adobe spool the message to disk rather than waiting for the SMTP handoff, so the cost is usually the render and compose — but with spooling disabled, or a slow template, the user’s browser eats that latency on every signup. Push the send into a job:
component extends="wheels.Job" { function config() { super.config(); this.queue = "mailers"; this.maxRetries = 5; }
public void function perform(required struct data) { user = model("User").findByKey(arguments.data.userId); new app.mailers.UserMailer().sendWelcome(user); }}The controller enqueues instead of sending inline:
component extends="Controller" { function create() { user = model("User").create(params.user); if (user.hasErrors()) { renderView(action="new"); return; } job = new app.jobs.SendWelcomeEmailJob(); job.enqueue(data={userId: user.id}); redirectTo(route="login", success="Welcome! Please log in."); }}Pass IDs through the job payload, not model instances — a serialized model re-hydrated from the queue drifts from the live record. The job fetches fresh on the worker.
A worker process picks up the job and runs perform(); retries happen automatically with exponential backoff. See Background Jobs for the full job lifecycle, worker CLI, and retry tuning.
Multi-part HTML + plain text
Section titled “Multi-part HTML + plain text”Some clients — screen readers, plain-text mail apps, spam filters — prefer a plain-text body. Ship both. Pass two templates to sendEmail() as a comma-separated list:
app/views/mailers/user/ welcome.cfm # HTML body welcome.txt.cfm # plain-text bodysendEmail( templates="/mailers/user/welcome,/mailers/user/welcome.txt", from="no-reply@example.com", to=user.email, subject="Welcome!", user=user);By default Wheels inspects both rendered bodies, picks the one with fewer < characters as the text part, and emits a multipart/alternative message — no contentType="multipart" flag required. If you’d rather be explicit, pass detectMultipart=false and put the text template first in the list.
Most mail clients prefer the HTML part when both are present; the plain-text part is the fallback. Both parts render from the same set of custom keys you passed to sendEmail().
Attachments
Section titled “Attachments”Pass file= (or its alias files=) with one or more paths. Paths without any directory separator (/ or \) are resolved relative to the filePath setting — default files, which expands relative to the web root, so public/files/ in the standard app template:
sendEmail( template="/mailers/billing/invoice", from="billing@example.com", to=user.email, subject="Your invoice", file="#user.id#-2026-04.pdf", user=user);Multiple attachments:
sendEmail( template="/mailers/billing/invoice", from="billing@example.com", to=user.email, subject="Your invoice and receipt", files="invoice-#user.id#.pdf,receipt-#user.id#.pdf", user=user);A path that does contain a separator skips the filePath resolution entirely and reaches cfmailparam unchanged — so a relative path like invoices/123.pdf resolves against the JVM working directory at delivery time, which is almost never what you want. For files outside public/files/, build an absolute path yourself (e.g. file="#ExpandPath('../storage/invoices/#user.id#.pdf')#"). Absolute paths and URLs work too (/var/app/pdfs/invoice.pdf, https://cdn.example.com/logo.png) — anything cfmailparam’s file attribute accepts is valid.
Per-environment SMTP
Section titled “Per-environment SMTP”Development rarely wants real mail going out. Point dev at a local capture tool (MailHog, Mailpit, Mailtrap); point production at the real provider. The config/<env>/settings.cfm convention picks up per-environment overrides:
set(functionName="sendEmail", server="localhost", port=1025, // MailHog default username="", password="", useTLS=false);set(functionName="sendEmail", server="smtp.postmarkapp.com", port=587, useTLS=true, username=env("POSTMARK_TOKEN"), password=env("POSTMARK_TOKEN"));Set the env var once in your deploy config; the same mailer code now routes mail correctly in every environment. See Environments and Configuration for the full per-environment settings pipeline.
Third-party services
Section titled “Third-party services”Postmark, SendGrid, AWS SES, Mailgun — they all expose an SMTP endpoint. The mailer code above doesn’t change per-provider; only the server, port, username, password defaults do.
| Provider | Server | Port | Username | Password |
|---|---|---|---|---|
| Postmark | smtp.postmarkapp.com | 587 | your server token | your server token |
| SendGrid | smtp.sendgrid.net | 587 | apikey (literal) | your API key |
| AWS SES | email-smtp.<region>.amazonaws.com | 587 | SES SMTP username | SES SMTP password |
| Mailgun | smtp.mailgun.org | 587 | postmaster@<domain> | Mailgun SMTP password |
Each provider has its own sender-verification and domain-setup flow (SPF, DKIM, DMARC). Follow their docs for that — the framework side is just SMTP.