Skip to content

Mail

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.

Terminal window
pnpm hery install mail
pnpm hery migrate --name add_mail_log

The install appends a MailLog model to prisma/schema/, so it leaves you one migration behind. hery install never runs Prisma itself.

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.

Terminal window
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.

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.

Terminal window
GET /mail/preview # the templates this app knows
GET /mail/preview/welcome?name=Ada&app=HeryJs

The 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”
Terminal window
pnpm hery make:mail WelcomeMail

That 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.

Terminal window
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:

Terminal window
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”
Terminal window
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.

Terminal window
hery.config.ts declares mail driver "resend" with driver "resend", but no module
is 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.

Terminal window
pnpm hery install mail-resend

It 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.

Terminal window
pnpm hery install mail-smtp

It 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.

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.ts declares, with the active one marked. Names only, never a credential.
  • Recent sends, the tenant’s MailLog newest 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, then sent, or failed with 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:

Terminal window
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 &amp; 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.

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:

Terminal window
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.

  • 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.
  • MailMessage is to, subject, html and 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 MailLog row records what happened; acting on it is yours.