A mailer is a file in app/mailers whose functions describe a message: who it goes to, what it is about and the data its view needs. henri renders it with the mail views in app/views/mailers and delivers it through nodemailer. In development you can look at every message in the browser without sending anything.
henri generate mailer welcome confirmwrites app/mailers/welcome.js, app/views/mailers/welcome/confirm.hbs and the shared layout, and
await henri.mailers.welcome.confirm(user).deliver();sends it.
The mailer
Section titled “The mailer”module.exports = { // Merged into every message of this mailer defaults: { from: 'Acme <no-reply@acme.com>', },
confirm: (user, token) => ({ to: user.email, subject: 'Confirm your address', data: { token, user }, }),
// Sample data for the previews: the arguments of the action previews: { confirm: () => [{ email: 'ada@example.com', name: 'Ada' }, 'abc123'], },};Every exported function is an action; defaults and previews describe the mailer instead. Files in sub-directories keep their path in the name (app/mailers/admin/digest.js is admin/digest).
An action returns the message. data is the context of the view, view and layout pick them; everything else is handed to nodemailer as it is — to, cc, bcc, replyTo, subject, attachments, headers, priority. Nothing is sent while the action runs: it returns a message, and the caller decides when it goes out.
Actions are called through henri.mailers:
const message = henri.mailers.welcome.confirm(user, token);
await message.deliver(); // render and send nowawait message.deliverLater(); // hand it to the delivery handlerconst { html, text, subject } = await message.render(); // render onlyhenri.mailers.deliver('welcome', 'confirm', user, token) is the one-liner, and the only way to reach a mailer whose name has a slash in it (henri.mailers.message('admin/digest', 'daily', [today])).
The views
Section titled “The views”A message renders app/views/mailers/<mailer>/<action> — Handlebars, the environment henri gives every application whatever its renderer, with the same partials your pages use (app/views/partials). Email is its own medium: no scripts, inline styles, tables for layout, so mail views are written on their own rather than reused from the pages. {{@mailer}}, {{@action}} and {{@localUrl}} are available as data variables.
<h1>Hello {{user.name}}</h1><p>Confirm your address with the code <b>{{token}}</b>.</p><p><a href='https://acme.com/confirm/{{token}}'>Confirm</a></p>A view engine that knows how to render mail itself takes over: an engine exposing renderMail({ view, layout, data, meta }) and answering { html, text } (or a string) is asked first. Neither the React nor the Inertia engine implements it today, so both render mail through Handlebars.
The view is read when the message is built, so a missing one fails the request that sends the mail — a signup, a password reset — rather than the boot. henri doctor reports a mailer action with no view (mailers.view); a mailer whose actions hand back their own html, or name their own view, is left alone, because a file cannot say which action does.
The layout
Section titled “The layout”app/views/mailers/layouts/mailer.hbs wraps every message. {{{body}}} is where the view goes; the signature, the footer and the unsubscribe link are written once.
<html> <body style='margin:0;padding:24px;background:#f4f4f5;'> {{{body}}} <p style='font-size:12px;color:#71717a;'>Sent by Acme</p> </body></html>config.mailers.layout picks another name, an action can pick its own with layout: 'newsletter', and layout: false renders the view alone.
The plain text part
Section titled “The plain text part”Every message is multipart. The text part is derived from the rich one unless you write it: no text view means the html is turned into readable text — blocks become blank lines, list items get a dash, links keep their target (Confirm (https://acme.com/confirm/abc123)), entities are decoded. Two copies of the same copy is exactly the pair that drifts apart, so henri only asks for one.
When the plain part deserves its own wording, write it next to the view and it wins:
app/views/mailers/welcome/confirm.hbs the rich partapp/views/mailers/welcome/confirm.text.hbs the plain part, authoredapp/views/mailers/layouts/mailer.hbs the layoutapp/views/mailers/layouts/mailer.text.hbs the layout of the plain partAn action that sets text itself wins over both.
Styling, and inlining CSS
Section titled “Styling, and inlining CSS”henri does not inline CSS, and it is not going to. Write the styles as style="" attributes: it is what the layout henri ships does, what henri generate mailer writes, and what every mail client agrees on. A <style> element in a mail view survives into the html untouched — henri neither reads it nor removes it — which means Gmail drops it, Outlook honours part of it, and a <link> never loads at all. The rules would simply not arrive.
The reason for the refusal is that the honest version of “apply that <style> block to the elements it matches” is four things: a CSS parser, a selector engine that gets specificity right, an html parser and an html serializer. Anything smaller is a regular expression over rendered html, and a mail that comes back mangled cannot be fixed after it is sent. So henri owns the seam and not the inliner:
// config/mail.js, app/modules/mail.js, or wherever the app bootsconst juice = require('juice');
henri.mailers.onRender((message) => { message.html = juice(message.html);});onRender runs on every message henri renders — a delivery, a deliverLater() and a /_mailers preview alike — and it runs last:
- Both parts already exist when the handler is called. The plain text part is derived from the html one, and that has happened by then, so an inliner rewriting
htmlcan never leak astyle=""attribute intotext/plain. - The handler receives the nodemailer payload and
{ mailer, action, view, layout }. Change the payload in place or answer a new one; anything else is ignored. - It may be async, and it may throw. Throwing fails the render — the message is not sent — which is the answer you want from a broken inliner.
juice is what to reach for; it is what most mail toolchains use, it keeps @media blocks in the <style> element where they belong, and it is one dependency in the application rather than one in the framework.
The mails henri sends itself
Section titled “The mails henri sends itself”One mailer ships with henri: auth, the three messages of the account flows — confirm an address, reset a password, confirm a new address. It is registered only in an application that turned one of those flows on, and it is an ordinary mailer otherwise: it shows up on /_mailers, its views live behind yours, and it is overridden in the two usual ways.
app/views/mailers/auth/reset.hbs(andreset.text.hbs) replaces one view. henri’s own views sit behind the application’s, so writing this file is enough.app/mailers/auth.jsreplaces the subjects, the sender or the data. An action it leaves out keeps henri’s, so a file with onlyresetin it still confirms addresses.
henri generate authentication writes both, which is the usual way to change the wording.
The same fallback applies to the layout: an application with no app/views/mailers/layouts/mailer.hbs gets henri’s, a plain centered shell, rather than an unwrapped body.
Previews
Section titled “Previews”While the server runs in development, http://localhost:3000/_mailers lists every mailer and every action, and renders one with the sample data declared in its previews. Nothing is delivered.
| Path | Answers |
|---|---|
/_mailers |
the mailers and their actions |
/_mailers/<mailer>/<action> |
the headers, and the rich part in a frame |
/_mailers/<mailer>/<action>?part=html |
the rich part alone |
/_mailers/<mailer>/<action>?part=text |
the plain part, as text/plain |
/_mailers/<mailer>/<action>?part=json |
the whole message as JSON |
previews maps an action to the arguments it should be called with, as an array. It may be a function (it receives the henri instance, so a preview can read a real record) and it may be async. An action with no entry is previewed with no arguments.
Like /_routes and /_controllers, the previews exist only in development (NODE_ENV neither production nor test) and answer only requests from the machine running the server: anything else gets a 404. "previews": false in the mailers configuration turns them off; nothing turns them on anywhere else.
Delivering later
Section titled “Delivering later”deliverLater() renders the message and hands it to a delivery handler instead of sending it inline. Install @usehenri/jobs and that handler is already registered: the rendered message becomes a job on the mailers queue, retried with a backoff and, out of attempts, kept in the dead letter queue like anything else.
await henri.mailers.welcome.confirm(user).deliverLater();await henri.mailers.welcome.confirm(user).deliverLater({ wait: '10m' });The options of the call are the options of an enqueue, so wait, at, queue and priority all work — and only those four: a misspelled wait used to be ignored, which is a mail that leaves immediately, so an option henri does not know is now refused and the right name is suggested. henri jobs sends them. See Jobs. Without a queue there is nothing to hold a message back, so a call carrying a wait or an at is refused and says to install the package rather than sending the mail now; deliverLater() on its own delivers out of band, as below.
Another queue plugs into the same seam:
henri.mailers.onDeliverLater((message, options) => myQueue.push('mail', message, options));The handler receives the rendered message — a plain, serializable object in nodemailer’s shape (from, to, subject, html, text, …) — and the options of the call. A worker that gets it back only has to call henri.mail.send(message); nothing has to be re-rendered, and no model has to be reachable from the worker.
Without a handler henri delivers out of band: the send is started, deliverLater() returns { deferred: true, handler: 'inline' } without waiting for it, and failures are logged. That is not a queue — nothing survives a restart — so it is a convenience for development, not a substitute. await henri.mailers.drain() waits for the deliveries in flight (henri.stop() does it too), which is what tests want.
Configuration
Section titled “Configuration”{ "mail": { "host": "smtp.example.com", "port": 587, "secure": false, "auth": { "user": "apikey", "pass": "..." } }, "mailers": { "from": "Acme <no-reply@acme.com>", "layout": "mailer" }}mail is nodemailer’s transport configuration and always goes through createTransport() then verify() on boot: a wrong host or wrong credentials fail the boot instead of the first send(). Without a mail key the module logs a warning and send() rejects.
Set "mail": "test" to use an Ethereal fake account: nothing is delivered, and the console prints a link to every message (the account is cached in .mailerTestCreds).
mailers holds the defaults of the mailers themselves: from, layout and previews. See Configuration.
Sending without a mailer
Section titled “Sending without a mailer”henri.mail.send() still takes a nodemailer message, and now refuses one with no recipient (HENRI_MAIL_NO_RECIPIENT): under NODE_ENV=test the transport is nodemailer’s json one, which happily “sends” a message nobody will ever receive, so a missing to used to be invisible until production.
await henri.mail.send({ from: '"Henri Server" <foo@example.com>', to: 'bar@example.com, baz@example.com', subject: 'Hello', text: 'Hello world?', html: '<b>Hello world?</b>',});It resolves with nodemailer’s info and logs the message id. henri.mail.nodemailer is the nodemailer package and henri.mail.transporter the configured transport, if you need them directly; the transport is closed when henri stops.
In tests
Section titled “In tests”Under NODE_ENV=test henri ignores the mail configuration and uses nodemailer’s JSON transport: send() works, nothing leaves the machine, and info.message is the message as JSON. Set henri.forceMail = true on the instance before it boots to use the configured transport in tests.
const info = await henri.mailers.welcome.confirm(user).deliver();const message = JSON.parse(info.message);
expect(message.subject).toBe('Confirm your address');expect(message.html).toContain('Hello Ada');expect(message.text).toContain('Hello Ada');henri.mailers.preview('welcome', 'confirm') renders an action with its sample data without sending it, which is a cheap way to keep a mail view honest in a test.
