Mail is a module and drivers, like everything else. MailService is the only thing you inject, a Mailable is the only thing you hand it, and which transport carries the message is a line in hery.config.ts — never a line in your resource.
pnpm hery install mailpnpm hery migrate --name add_mail_logThe install appends a MailLog model to prisma/schema/, so it leaves you one migration behind. hery install never runs Prisma itself.
One mail type, one file
Section titled “One mail type, one file”pnpm hery make:mail WelcomeMail writes a class into
src/functional/<domain>/, and that class is the template: its subject and
its body live together, typed against whatever it needs to render.
export class WelcomeMail implements Mailable { constructor(readonly to: string) {}
build(): MailMessage { return { to: this.to, subject: 'Welcome', html: mailLayout('<p>Glad you are here.</p>', { title: 'Welcome' }), }; }}The alternative – a shared record keyed by a string, which is what
mail.templates.ts still holds for the older queue(to, template, data) path
– puts every mail in one file and gives none of them a type. Adding the second
one is where that stops scaling.
mailLayout is the shell they share: one tested document, inline styles, a
single column. Every mail client strips a stylesheet, and half of them mangle
anything wider than one column on a phone, so the layout is deliberately one
function taking a body rather than a partials system whose output nobody checks
in Outlook.
Its optOut is absent by default and legally required in several countries for
anything that is not transactional. Only the caller knows which kind it is
sending, which is why it is a parameter rather than a constant.
Previewing what you are about to send
Section titled “Previewing what you are about to send”The log driver reports the recipient and the subject. The body – the part you are actually writing – was the one thing nothing showed, so authoring a mail meant reading the HTML and imagining it.
GET /mail/preview # the templates this app knowsGET /mail/preview/welcome?name=Ada&app=HeryJsThe second renders in the browser, subject included: a preview that hides the subject lets a broken one ship unnoticed.
Both are behind DevOnlyGuard. They render arbitrary templates with
caller-supplied values, which is a preview in development and an open rendering
endpoint anywhere else.
This covers the mail.templates.ts record. A Mailable takes constructor
arguments the route cannot invent, so previewing one means instantiating it –
in a spec, or in the console.
A mailable says what to send, never who sends it
Section titled “A mailable says what to send, never who sends it”pnpm hery make:mail WelcomeMailThat writes a class into src/functional/<domain>/, next to the code that uses it — the domain is derived from the name with a trailing mail stripped, so WelcomeMail lands in src/functional/welcome/, and --domain user puts it where you meant instead.
export class WelcomeMail implements Mailable { constructor(readonly to: string) {}
build(): MailMessage { return { to: this.to, subject: 'Welcome', html: '<p>Write what WelcomeMail says here.</p>', }; }}Mailable is two members: a to, and a build() returning { to, subject, html }. build() may be async, so a mailable that has to load a template from disk or mint a signed link can do it without its caller knowing which ones need to. A mailable never picks a driver, reads config, or touches a vendor SDK — that is the registry’s job, and keeping it out is exactly what lets the same WelcomeMail keep working the day the transport changes underneath it.
Sending is one call, and it names nothing about transport:
await this.mail.send(new WelcomeMail(user.email));Every send is queued, and every send leaves a row
Section titled “Every send is queued, and every send leaves a row”MailService.send() does not send. It builds the message, writes a MailLog row with status: 'queued' and the current tenant, then dispatches a mail.send job. The row is written before the job is dispatched, so a message can never be handled by a worker that has nothing to record its outcome against.
MailProcessor picks the job up off the default queue, asks the registry for the active driver, and flips the row to sent with a timestamp or to failed with the error message. The processor reads the driver per job rather than caching it in a field: the worker is long-lived, and resolving on use keeps it honest if the active driver is swapped under it.
The MailLog row is the point. A queued mail that never arrived is otherwise invisible — the row makes “did we send it, and what happened” a query rather than a log-grep. GET /mail?page=&limit= returns the log for the current tenant, newest first, behind SessionGuard, CapabilitiesGuard and its own canReadMailLog. That policy is admin-only, with no own fallback: the log carries every recipient address the tenant ever wrote to, which is the tenant’s whole correspondence rather than the caller’s.
The default driver logs, and that is what makes a fresh app safe
Section titled “The default driver logs, and that is what makes a fresh app safe”LogMailDriver implements the whole contract honestly and writes to=… subject="…" to the Nest logger. Nothing leaves the process. A freshly generated app therefore cannot accidentally email a live person, because the driver it got by default has no way to.
It ships inside the module rather than as a package, and the registry holds it by constructor injection instead of resolving it by token, because it is the one driver guaranteed to be present.
One config line swings the app from logging to sending
Section titled “One config line swings the app from logging to sending”mail: { default: process.env.MAIL_DRIVER ?? 'log', drivers: { log: { driver: 'log' }, resend: { driver: 'resend' }, },},drivers declares what exists; default names which one is active. Mail is a single-active-driver module — a resource says “send this”, never “send this over Resend” — so MailDriverRegistry exposes only active, and no call site can override it.
MAIL_DRIVER is a real environment variable now, and it selects a name rather than carrying a secret. That is the one legitimate use of process.env inside hery.config.ts: MAIL_DRIVER=log in development, MAIL_DRIVER=resend in production, and no diff between the two. Credentials never appear here; they belong to the driver, through parseModuleEnv.
Leave the mail slice out entirely and the registry falls back to a single log driver, which is what a project that has not thought about mail yet should get.
A declared driver that is not installed stops the boot
Section titled “A declared driver that is not installed stops the boot”Resolution runs in onModuleInit, and every declared driver is resolved — not only the active one. A project that declares Resend but runs on log locally finds out the Resend package is missing on its own machine, rather than the first time production starts with MAIL_DRIVER=resend.
hery.config.ts declares mail driver "resend" with driver "resend", but no moduleis installed to provide it. Run "pnpm hery install mail-resend" or remove"resend" from hery.config.ts.A default naming a driver that is not in drivers fails the same way, at the same moment, listing what is declared. Neither case ever falls back to log: an app that silently logs mail instead of sending it looks healthy until a user reports an email that never arrived, which is strictly worse than an app that refuses to start.
Resend is one package away
Section titled “Resend is one package away”pnpm hery install mail-resendIt lands in src/modules/mail rather than a folder of its own. That destination is load-bearing: .dependency-cruiser.cjs forbids one module importing another, so a driver living in src/modules/mail-resend could never reach the MailDriver contract — and landing beside the module it extends also means uninstalling mail takes its drivers with it.
Import ResendMailModule into src/app.module.ts, declare resend: { driver: 'resend' }, and set RESEND_API_KEY and RESEND_FROM. Those two are parsed lazily, the first time the driver actually sends, so an app that declares Resend but runs on log is never refused a boot over credentials it will never use.
The driver is one POST over fetch, with no SDK dependency — a package that added nothing but a typed wrapper around a single request would still have to be installed, audited and upgraded by everyone who only wanted to send mail. A non-2xx from Resend throws rather than logging, because the throw is what flips the MailLog row to failed and lets BullMQ see the job as failed.
SMTP is one package away too
Section titled “SMTP is one package away too”pnpm hery install mail-smtpIt lands in src/modules/mail for the same reason Resend does. Import SmtpMailModule into src/app.module.ts, declare smtp: { driver: 'smtp' }, and set the connection in the environment:
| Variable | Meaning |
|---|---|
MAIL_SMTP_HOST |
The server to dial. Required. |
MAIL_SMTP_PORT |
Defaults to 587. |
MAIL_SMTP_TLS |
starttls (default), implicit for a TLS socket from the first byte, or none. |
MAIL_SMTP_USER |
Optional, and only valid together with MAIL_SMTP_PASSWORD. |
MAIL_SMTP_PASSWORD |
Optional, and only valid together with MAIL_SMTP_USER. |
MAIL_SMTP_FROM |
The sender address. Required. |
starttls refuses to send if the server will not upgrade the connection, rather than quietly continuing in the clear; none is for a local catcher such as Mailpit and is the only setting that can put a credential on the wire unencrypted. A user without a password, or the reverse, is refused before anything is sent. Like Resend, the variables are parsed lazily on the first send, so an app that declares SMTP but runs on log is never refused a boot over a host it will never dial.
The driver uses nodemailer rather than writing the SMTP dialogue by hand, and opens a connection per message instead of pooling: every send is already a queued job, so the connection is off any request’s path, and a pooled socket on a long-lived worker would outlive a rotated credential. A refused recipient throws, for the same reason a Resend refusal does. Its spec runs the driver against a real in-process SMTP server, so delivery, authentication and refusal are exercised over an actual socket.
Managing mail from the admin
Section titled “Managing mail from the admin”MailAdminController contributes to the admin panel through the admin contract, so there is no mail page to install. Under a Messaging group the panel shows:
- Mail drivers, one row per driver
hery.config.tsdeclares, with the active one marked. Names only, never a credential. - Recent sends, the tenant’s
MailLognewest first: recipient, subject, status, the driver’s error when it failed, and when it was queued and sent. - Send test mail, an action asking for an address and queuing a real message through the same path every mail takes. The row it leaves in Recent sends is the proof:
queued, thensent, orfailedwith the transport’s own error.
All three are admin-only (canReadMailLog, canSendTestMail), behind the studio gate, and the test send is audited like any admin action. The controller is separate from /mail because that one carries a development-only route, and a controller that contributes to the admin has to exist in production too.
Templates are a second, smaller path — and they do not escape
Section titled “Templates are a second, smaller path — and they do not escape”MailService.queue(to, template, data) skips the mailable and renders a named template instead:
await this.mail.queue(user.email, 'welcome', { name: user.name, app: 'Acme' });Templates are a Record in mail.templates.ts and interpolation is {{key}} substitution. No engine, no template files to locate at runtime, one shipped example (welcome) to copy. An unknown template name throws rather than sending a blank message, and a missing key becomes an empty string rather than an error.
Values are escaped into the body. They were not, and the values a template carries are exactly the ones that come from a request — a display name, a company, an order reference — so anything a user typed reached the recipient’s mail client as markup. Escaping happens in renderTemplate rather than at the call site: a call site that forgets is indistinguishable from one that had nothing to escape.
The subject is not escaped, because it is plain text in every mail client and escaping it would show a reader & where they wrote &. A template that genuinely needs to inject markup has no opt-in today; add one deliberately if that day comes, rather than reverting this.
Prefer make:mail for anything a person will read. A mailable is a typed class your compiler checks; a template name is a string nothing checks.
Retries are not configured
Section titled “Retries are not configured”The job is dispatched with no options, so BullMQ’s defaults apply: one attempt, no retry, no backoff. A failed send marks the row failed and the job stays in the failed set. If mail matters, set attempts and backoff on the dispatch before you go live.
The BullMQ dashboard is mounted at /jobs when NODE_ENV is not production. It is mounted at the Express level, so no Nest guard applies and it is unauthenticated, and it shows the whole queue across every tenant. A local development tool, not an admin surface.
Auth’s two emails go through the same service
Section titled “Auth’s two emails go through the same service”The kernel’s password-reset and email-verification flows need to send mail, and the kernel may not import an uninstallable module. So auth declares an AUTH_MAILER contract and looks its implementation up by token; the mail module binds it. Installing mail is what turns those two messages from logged warnings into real emails.
With nothing bound, the email is dropped and the reason is logged once per attempt rather than thrown — a password-reset request must not 500 because the app chose not to install mail:
Wanted to email someone@example.com but no mail module is installed to send it.Run "pnpm hery install mail" to have auth emails delivered.Both messages are ordinary mailables (ResetPasswordMailable, VerifyEmailMailable), so they are testable without a mail driver at all, and the reset link is taken from Better Auth rather than rebuilt from an app URL — only the URL Better Auth hands over is guaranteed to carry the token in the shape its own verification route expects.
What mail deliberately does not do
Section titled “What mail deliberately does not do”- No opinion on the transport. Resend and SMTP are both a package away, and neither is the default: choosing between them has cost, deliverability and compliance consequences the framework does not decide for you. SMTP carries no DKIM, SPF or DMARC setup either; that belongs to the server you point it at.
MailMessageisto,subject,htmland nothing else. No cc, no bcc, no attachments, no plain-text alternative. Every one of those is a real feature, and none of them is in the contract today — adding one means widening the contract for every driver at once, which is the deliberate friction.- No per-call driver selection. Unlike export, mail has no
as(). Nothing in a resource should get to decide that this particular message goes over a different transport. - No retry policy, no bounce handling, no suppression list. The
MailLogrow records what happened; acting on it is yours.