@mahiframework/mail is a message builder (Mailable), a driver resolver
(MailManager), and three transports. A Mailable describes what to
send and to whom; a transport decides how.
import { Mail, Mailable, Envelope, Content } from "@mahiframework/mail";
export class WelcomeMailable extends Mailable {
constructor(private user: User) {
super();
}
envelope(): Envelope {
return new Envelope({
subject: "Welcome to Acme",
to: [{ address: this.user.email, name: this.user.name }],
tags: ["welcome"],
});
}
content(): Content {
return new Content({ html: () => renderWelcome(this.user) });
}
}
await Mail.send(new WelcomeMailable(user));
Note the call shape: Mail.send(mailable). Recipients live on the
mailable, not on the facade. More on that below.
Configuration
config/mail.ts:
import type { MailConfig } from "@mahiframework/mail";
import type { Env } from "./env.js";
export function mailConfig(env: Env): MailConfig {
return {
default: env.MAIL_MAILER,
from: { address: env.MAIL_FROM_ADDRESS, name: env.MAIL_FROM_NAME },
themes: {
default: { productName: env.MAIL_FROM_NAME },
alternative: { productName: env.MAIL_FROM_NAME, primaryColor: "#7c3aed" },
},
mailers: {
log: {},
array: {},
smtp: {
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_PORT === 465,
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD ?? "" } : undefined,
pool: true,
},
},
};
}
interface MailConfig {
default: string;
mailers: Record<string, unknown>;
from?: { address: string; name?: string };
afterCommit?: boolean;
themes?: Record<string, MailThemeConfig>;
}
mailers is Record<string, unknown> because each transport parses its
own config shape, MailManager.mailerConfig(name) hands the raw entry
to the factory, which casts it. A mailer with no options (log, array)
still wants an entry so it's visible in the file.
The global from
config.from is a process-wide default sender, applied inside
Mailable.render() to any message whose envelope set no from of its
own:
return {
from: envelope.from ?? globalFrom,
// ...
};
Most apps set this once and never call .from() on a mailable again. A
mailable that does set from() wins. The global is a fallback, not an
override. If neither is set, RenderedMail.from is undefined and the
transport decides what that means (nodemailer will typically reject it;
the log and array transports don't care).
A generated app defaults MAIL_MAILER to "log", so local development
and tests never open an SMTP connection by accident.
themes configures MailMessage rendering. See
MailMessage and themes.
The transports
interface MailTransport {
send(message: RenderedMail): Promise<SentMessage>;
}
One method. A transport never sees a Mailable, only the flattened
RenderedMail. That boundary is what makes transports trivially testable
and mailables independent of delivery.
log
Writes the rendered message through app.logger instead of sending it.
manager.extend("log", () => new LogTransport(app.logger));
[2026-08-27 09:14:02] local.INFO: Mail: Welcome to Acme {"from":"Example <hello@example.com>","to":"Ada Lovelace <ada@example.com>","body":"Hi Ada,\n\nWelcome."}
The body is logged verbatim, text preferred, HTML as fallback, so
what you see is a faithful preview, not a summary. messageId is a
synthetic log-1, log-2, ….
Right for: local development, and any environment where you want to see what would have gone out without configuring a mail server. This is the default in a new app.
Note that LogTransport takes app.logger, the always-available
ConsoleLogger on Application, not a LOG_TOKEN channel. That's
deliberate: it means MailServiceProvider has no ordering dependency on
LoggingServiceProvider (which is opt-in and may not be registered at
all) and can go anywhere in providers[]. See
Logging for why those are two
different loggers.
array
Captures every message in memory and sends nothing.
export class ArrayTransport implements MailTransport {
readonly messages: RenderedMail[] = [];
flush(): void; // discard everything captured
}
Right for: tests. Point mail.default at "array", resolve the mailer,
and assert with ordinary matchers:
const transport = manager.mailer("array") as ArrayTransport;
await manager.send(new WelcomeMailable(user));
expect(transport.messages).toHaveLength(1);
expect(transport.messages[0].subject).toBe("Welcome to Acme");
expect(transport.messages[0].to[0].address).toBe("ada@example.com");
This is why there are none of Laravel's ~30 assertSentTo /
assertQueued / assertHasSubject helpers. The captured RenderedMail
is a plain object; Vitest already knows how to assert on plain objects.
Remember it's a resolved and cached driver, the same ArrayTransport
instance is returned on every mailer("array"), so call flush() between
tests or build a fresh Application.
smtp
Real delivery via nodemailer, the package's one runtime dependency.
interface SmtpTransportConfig {
host: string;
port?: number;
secure?: boolean; // true for implicit TLS (465); false for STARTTLS/plain
auth?: { user: string; pass: string };
pool?: boolean; // passed through to createTransport
requireTLS?: boolean; // fail rather than send in the clear
tls?: { rejectUnauthorized?: boolean; servername?: string; ciphers?: string };
}
secure: falsedoes not mean "no encryption", and it does not mean "encryption guaranteed" either. It means opportunistic: STARTTLS is used when the server advertises it and silently skipped when it does not. A relay that stops offering STARTTLS, misconfigured, or impersonated, downgrades you to plaintext with no error, and the call site cannot tell the difference. The promise resolves,acceptedlists the recipient, and the password and body crossed the wire in the clear.Set
requireTLS: trueon any submission port (587) to make that a hard failure.SMTP_REQUIRE_TLS=truein a generated app.
tls.rejectUnauthorizeddefaults totrueand should stay there. Setting it tofalsedisables certificate verification entirely, which makes the connection trivially interceptable. It exists for a self-signed cert on an internal relay, not for silencing a certificate error in production.
Call close() to tear down a pooled transporter. Only meaningful with
pool: true, where nodemailer keeps sockets open for reuse and those
sockets hold the event loop open. A short-lived process would otherwise
hang until they idle out.
The nodemailer Transporter is created lazily on first send(), not
in the constructor. Merely resolving the smtp mailer, which
Manager.driver() does synchronously, possibly during boot, never opens
a connection or a pool. That's the same "cheap synchronous construction,
lazy I/O" contract every driver in the framework follows.
On send it maps RenderedMail onto nodemailer's options, plus two header
conventions:
RenderedMail field | SMTP header |
|---|---|
tags: ["welcome", "onboarding"] | X-Tag: welcome,onboarding |
metadata: { userId: "42" } | X-Metadata-UserId: 42 |
headers: { ... } | merged in first; the above win on collision |
Note the capital in X-Metadata-UserId: nodemailer title-cases each
dash-separated segment on the wire. Header names are case-insensitive per
RFC 5322, so this is correct, but a receiving system doing an exact-match
lookup on the lowercase form will miss it.
SentMessage.accepted / rejected are populated from nodemailer's
per-recipient response. The SMTP transport is the only built-in one that
reports genuine per-recipient outcomes. log and array treat every
recipient as accepted.
Right for: production, and for a local catch-all like Mailpit or MailHog
(SMTP_HOST=127.0.0.1, SMTP_PORT=1025, the defaults in a generated
app's env schema).
Mailable
Two interchangeable styles, mirroring Laravel's modern Mailable.
Declarative
Override envelope(), content(), and/or attachments() and return the
objects directly. This is the usual style for a named message.
export class InvoicePaidMailable extends Mailable {
constructor(private invoice: Invoice) {
super();
}
envelope(): Envelope {
return new Envelope({
subject: `Invoice ${this.invoice.number} paid`,
to: [{ address: this.invoice.email }],
replyTo: [{ address: "billing@example.com", name: "Billing" }],
metadata: { invoiceId: this.invoice.id },
});
}
content(): Content {
return new Content({
html: () => renderInvoiceHtml(this.invoice),
text: () => renderInvoiceText(this.invoice),
});
}
attachments(): Attachment[] {
return [{ filename: `invoice-${this.invoice.number}.pdf`, path: this.invoice.pdfPath }];
}
}
Fluent
Override build() and chain the setters. Handy for ad-hoc messages
assembled imperatively.
export class ReminderMailable extends Mailable {
constructor(private email: string, private days: number) {
super();
}
build(): void {
this.subject(`${this.days} days left on your trial`)
.to(this.email)
.replyTo("support@example.com", "Support")
.tag("trial-reminder")
.html(`<p>Your trial ends in ${this.days} days.</p>`)
.text(`Your trial ends in ${this.days} days.`);
}
}
The two styles are not exclusive but they don't compose: the fluent
setters mutate the _envelope/_content instances that the default
envelope()/content() return. A subclass that overrides envelope()
ignores everything to()/subject()/tag() put there.
The fluent API
| Method | Effect |
|---|---|
to(address, name?) | Append a To recipient. |
cc(address, name?) | Append a Cc recipient. |
bcc(address, name?) | Append a Bcc recipient. |
replyTo(address, name?) | Append a Reply-To. |
from(address, name?) | Set the sender (replaces, doesn't append). |
subject(text) | Set the subject. |
tag(name) | Append a tag. |
metadata(key, value) | Set one metadata pair. |
view(render) | HTML body from a thunk: () => string | Promise<string>. |
html(html) | HTML body from an already-rendered string. |
textView(render) | Text body from a thunk. |
text(text) | Text body from an already-rendered string. |
attach(file) | Append an Attachment. |
header(key, value) | Set one extra header (smtp only). |
Every one returns this. to/cc/bcc/replyTo/tag/attach
append; from/subject/view/html/textView/text replace.
metadata/header set one key at a time, overwriting that key.
The metadata key/value form sets one pair at a time, pass a whole
metadata record to new Envelope({ ... }) if you have many.
Envelope
class Envelope {
from?: Address;
to: Address[] = [];
cc: Address[] = [];
bcc: Address[] = [];
replyTo: Address[] = [];
subject = "";
tags: string[] = [];
metadata: Record<string, string> = {};
constructor(options?: EnvelopeOptions);
}
Everything except the body and attachments. Mutable, not a frozen value
object. The fluent path needs to build it incrementally. The
immutability that matters is at the RenderedMail boundary handed to a
transport.
tags and metadata are pass-through: transports that understand them
use them (SMTP maps them to headers), the others just carry them along.
Content and lazy body thunks
type BodyRenderer = () => string | Promise<string>;
class Content {
html?: BodyRenderer;
text?: BodyRenderer;
constructor(options?: { html?: BodyRenderer | string; text?: BodyRenderer | string });
}
Bodies are stored as thunks, not strings. The constructor accepts
either, a plain string is wrapped into a constant thunk (() => value),
but internally there is only ever a function, and it is not invoked
until render().
This matters for two reasons.
Rendering is often async. Reading a template file, calling out to a
template engine, fetching something the message needs. All of that is a
promise. If Content held a string, constructing a Mailable would have
to be async, and new WelcomeMailable(user) would become
await WelcomeMailable.make(user). A thunk pushes the await to exactly
one place: render(), which is already async because sending is.
The app chooses the template engine. There is deliberately no
Blade-style view(name, data) template system in this package. No
.blade.php equivalent, no component library, no view resolver, no
compiled template cache, no resources/views convention. Laravel can
ship one because Blade is already in the framework; TypeScript has no
default answer, and picking one (JSX? a .hbs loader? template
literals?) would bake a decision every app would then have to fight.
A thunk is the smallest interface that accommodates all of them:
// Template literal — no dependencies at all
content() {
return new Content({ html: () => `<p>Hi ${escapeHtml(this.user.name)}</p>` });
}
// A file on disk
content() {
return new Content({
html: async () => renderTemplate(await readFile("emails/welcome.html", "utf8"), this.user),
});
}
// Whatever engine you installed
content() {
return new Content({ html: () => engine.render("welcome", { user: this.user }) });
}
Mailable.view(render) is the fluent equivalent of the first. Note the
name: view() takes a renderer function, not a template name. It has
nothing to do with Laravel's view($name, $data).
Either body may be omitted. Both omitted is legal and produces a message with no body. The transports won't stop you.
Attachments
interface Attachment {
filename: string;
content?: Buffer | Uint8Array | string;
path?: string;
contentType?: string;
cid?: string;
}
Exactly one of content or path. content is the raw bytes (or a
UTF-8 string); path is a filesystem path the transport reads at send
time. Nothing validates that you supplied precisely one, supplying
neither yields an empty attachment, supplying both is up to the transport
(nodemailer prefers content).
contentType overrides the transport's MIME guess. cid marks the
attachment as inline, for embedding in HTML:
content() {
return new Content({ html: () => `<img src="cid:logo@acme">` });
}
attachments(): Attachment[] {
return [{ filename: "logo.png", path: "assets/logo.png", cid: "logo@acme" }];
}
Omit cid for an ordinary file attachment.
This is narrower than Laravel's Illuminate\Mail\Attachment, which
carries a whole strategy object for from-path / from-storage / from-data
resolution. Here an attachment is just resolved bytes-or-path plus
metadata; how the bytes were obtained is the caller's business:
import { Storage } from "@mahiframework/storage";
// A local disk hands you a real filesystem path — let the transport read it.
attachments(): Attachment[] {
return [{ filename: "invoice.pdf", path: Storage.path(this.invoice.path, "local") }];
}
attachments() is synchronous, so if you need to pull the bytes
yourself, do it in build() (which may be async) and stash the result:
export class InvoiceMailable extends Mailable {
constructor(private invoice: Invoice) {
super();
}
async build(): Promise<void> {
this.subject(`Invoice ${this.invoice.number}`)
.to(this.invoice.email)
.attach({
filename: `invoice-${this.invoice.number}.pdf`,
content: await Storage.get(this.invoice.path),
contentType: "application/pdf",
});
}
}
See Storage.
Address formatting
interface Address {
address: string;
name?: string;
}
formatAddress({ address, name }): string
formatAddressList(addresses: Address[]): string
Address is a plain interface, not a class. It carries no behaviour,
only data flowing from an Envelope into a RenderedMail and out to a
transport. The one piece of logic is formatAddress(), a free function so
transports can share it.
formatAddress({ address: "ada@example.com" })
// "ada@example.com"
formatAddress({ address: "ada@example.com", name: "Ada Lovelace" })
// "Ada Lovelace <ada@example.com>"
formatAddress({ address: "ops@example.com", name: "Example, Inc." })
// '"Example, Inc." <ops@example.com>'
formatAddress({ address: "x@example.com", name: 'He said "hi"' })
// '"He said \\"hi\\"" <x@example.com>'
The quoting rule: if the display name contains any of , < > " or
@, wrap it in double quotes and backslash-escape any embedded " or
\. Otherwise emit it bare. That's RFC 5322's requirement, an unquoted
comma in a display name would be read as a recipient separator, turning
Example, Inc. <ops@example.com> into two addresses, one of which is
garbage.
formatAddressList() comma-joins. Both are used by LogTransport for
its output and by SmtpTransport for the From header.
MailMessage and themes
Most transactional email is a greeting, some paragraphs, one button and a sign-off. Writing that as a hand-rolled HTML thunk every time means every message in the app re-decides its own markup, and they drift.
MailMessage is a fluent builder for exactly that shape, rendered by a
swappable theme:
import { Mail, MailMessage } from "@mahiframework/mail";
await Mail.send(
new MailMessage()
.to(user.email, user.name)
.subject("Reset your password")
.greeting(`Hi ${user.name},`)
.line("You are receiving this because we received a password reset request.")
.button("Reset Password", url)
.line("This link expires in 60 minutes.")
.footer("If you didn't request a reset, no further action is required."),
);
MailMessage extends Mailable. It is not a parallel type. It goes
anywhere a mailable goes (Mail.send(), a notification's toMail(), a
test's render()) with the same to()/cc()/subject()/attach()
setters. This is one place the framework is deliberately simpler than
Laravel, where MailMessage and Mailable are unrelated classes with
overlapping APIs and only the former works inside a notification.
Selecting a theme
new MailMessage() // the "default" theme
new MailMessage("alternative") // a theme registered under that name
new MailMessage(new MyTheme()) // an ad-hoc instance, no registration
The string form is primary, and it is a string for consistency: this is
how every named thing in the framework is resolved: Mail.mailer("smtp"),
Cache.store("redis"), queue.connection("redis").
A subclass may bake one in via static theme, the same class-level
metadata idiom as Job.unique, Notification.type and Model.morphName:
class AlertMessage extends MailMessage {
static override theme = "alert";
}
A constructor argument beats static theme.
Registering themes
The cheap path is config alone, any name listed under mail.themes gets
the built-in renderer configured with those settings:
// config/mail.ts
themes: {
default: { productName: "Acme" },
alternative: { productName: "Acme", primaryColor: "#7c3aed" },
}
That is enough for new MailMessage("alternative") to render in a
different accent colour with no code. An unknown name throws rather
than falling back to the default theme. A silent fallback would send a
message in the wrong brand and look like it worked.
For a theme that needs real logic, register a factory from a provider:
mail.extendTheme("alternative", (settings) => new MyTheme(settings));
The factory receives that theme's mail.themes.<name> config, exactly as
a transport factory receives its mailer config. Theme registration is a
registry parallel to extend(), not a reuse of it: themes and transports
are orthogonal, and a message in any theme can go out through any mailer.
Writing a theme
interface MailTheme {
render(data: MailMessageData): RenderedBody | Promise<RenderedBody>;
}
interface RenderedBody { html: string; text: string; }
A theme is an object, not a template file. This package ships no
template engine and doesn't want one (see Content above), so rather than
invent a miniature one just for MailMessage, the extension point is an
interface. A theme can be template literals with no dependencies (that's
what DefaultMailTheme is), a wrapper around react-email/mjml/handlebars,
or a subclass of DefaultMailTheme overriding a single method:
class BrandTheme extends DefaultMailTheme {
protected override button(label: string, url: string): string {
return `<a class="btn" href="${escapeHtml(url)}">${escapeHtml(label)}</a>`;
}
}
All three plug in identically, and the authoring API is unchanged by the choice, changing how mail looks never means rewriting the code that decides what it says.
render() receives a MailMessageData: a level, optional greeting
and salutation, a footer array, and an array of plain-data blocks
(line, button, panel, table). No HTML strings, no functions. A
theme gets a description of the message, never a half-rendered fragment.
Escaping is the theme's job. MailMessageData carries raw text,
because the correct escaping depends on which half you're producing: a
line needs & in HTML and a bare & in text. Pre-escaping would
corrupt the plain-text body. Route every HTML interpolation through
escapeHtml(), message bodies routinely contain user-controlled data,
and an unescaped hole is a live XSS vector in webmail clients.
Both halves are produced in one call because a theme derives them from the
same walk, and because shipping HTML-only mail is a mistake worth making
structurally awkward. It's penalised by spam filters and unreadable in
text-only clients. The bundled theme renders each half independently
rather than tag-stripping the HTML, since the useful text form of a button
is Label: https://url, which stripping would discard entirely.
Testing a MailMessage
Assert on the structure, not the markup:
const message = new MailMessage().line("Hi").button("Go", "https://example.com");
expect(message.data().blocks).toContainEqual({
type: "button", label: "Go", url: "https://example.com", level: "info",
});
data() returns the accumulated IR without rendering anything, so the
test doesn't couple to whichever theme happens to be active.
render() and RenderedMail
async render(globalFrom?: Address): Promise<RenderedMail>
The whole flattening step, in order:
await this.build(): the imperative hook, if the subclass has one.- Read
envelope(),content(),attachments(). awaitthehtmlandtextthunks, if present.- Apply
globalFromwhen the envelope set nofrom. - Return a plain
RenderedMail.
interface RenderedMail {
from?: Address;
to: Address[];
cc: Address[];
bcc: Address[];
replyTo: Address[];
subject: string;
html?: string;
text?: string;
attachments: Attachment[];
tags: string[];
metadata: Record<string, string>;
headers?: Record<string, string>;
}
This is the single value type crossing into a transport.
Only the smtp transport forwards headers; log and array carry them
on the captured message but do nothing with them.
Ad-hoc messages
For a one-off that doesn't warrant its own class, Message is a concrete
Mailable:
import { Mail, Message } from "@mahiframework/mail";
await Mail.send(
new Message()
.to("ops@example.com")
.subject("Alert")
.text("Something broke.")
.header("X-Priority", "1"),
);
Mailable is abstract with no abstract members, so Message is
class Message extends Mailable {} and nothing more.
You can still hand a RenderedMail straight to a transport:
await Mail.mailer("smtp").send(rendered);
but prefer Message unless you already have a RenderedMail in hand.
The transport path bypasses Mailable.validate() entirely, no CRLF
header-injection guard, no completeness check. It was previously the only
documented way to set a header, which meant the one route to the classic
injection sink was also the one route with no injection guard. header()
fixes that; the transport escape hatch remains for callers who genuinely
need it.
This is not Laravel's Mail::raw($text, $callback). That exists because
Laravel's Mailable can't be instantiated, and its callback form
conflicts with this package's rule that recipients live on the mailable.
You can also call render() yourself, to assert on the output in a test,
or to inspect a message before sending it:
const rendered = await new WelcomeMailable(user).render();
expect(rendered.subject).toBe("Welcome to Acme");
expect(rendered.html).toContain(user.name);
render() is not idempotent in a meaningful sense: it re-runs build()
and re-invokes the body thunks every call. Render once and reuse the
result if the thunks are expensive.
SentMessage
interface SentMessage {
messageId: string;
original: RenderedMail;
accepted: string[];
rejected: string[];
}
The result of a transport accepting a message for delivery, not proof
of delivery. messageId is transport-assigned (SMTP's Message-ID
header, or a synthetic log-1/array-1). original is the exact
RenderedMail handed over, retained so callers don't have to re-derive
it. accepted/rejected echo per-recipient outcomes for transports that
report them.
MailManager
class MailManager extends Manager<MailTransport>
| Method | Returns | Notes |
|---|---|---|
mailer(name?) | MailTransport | Alias for driver(). Default mailer when omitted. |
mailerConfig(name) | unknown | The raw mailers[name] entry. |
send(mailable, options?) | Promise<SentMessage> | Render, then hand to a mailer. |
getDefaultDriver() | string | config.default. |
extend(name, factory) | this | Register a transport. |
async send(mailable: Mailable, options?: { mailer?: string }): Promise<SentMessage> {
const rendered = await mailable.render(this.config.from);
return this.mailer(options?.mailer).send(rendered);
}
That's the whole entry point application code uses. The global from is
applied here, via render(), so a mailable that omits from() still
leaves with a sender regardless of which mailer sends it.
await mail.send(new WelcomeMailable(user));
await mail.send(new WelcomeMailable(user), { mailer: "smtp" });
MailServiceProvider registers all three built-ins via extend(), the
same mechanism a plugin would use to add ses:
this.app.singleton(MAIL_TOKEN, (app) => {
const config = app.config.get<MailConfig>("mail");
const manager = new MailManager(app, config);
manager.extend("smtp", () => new SmtpTransport(manager.mailerConfig("smtp") as SmtpTransportConfig));
manager.extend("log", () => new LogTransport(app.logger));
manager.extend("array", () => new ArrayTransport());
return manager;
});
No boot(): no built-in transport needs async warm-up.
Sending after a transaction commits
A mailable sent inside a DB.transaction() is delivered immediately by
default. Override afterCommit() to return true (or set afterCommit: true in the mail config for a process-wide default) and the send is held
until the transaction commits, and dropped if it rolls back:
class OrderShipped extends Mailable {
override afterCommit() { return true; }
// ...
}
await DB.transaction(async () => {
const order = await Order.create({ ... });
await Mail.send(new OrderShipped(order)); // sent after commit
});
When deferred, send() resolves with a placeholder SentMessage
({ deferred: true }). The transport runs later, so messageId/
accepted/rejected aren't known yet. Outside a transaction it sends
immediately and returns the transport's real result. A mailable's own
afterCommit() beats the config default. Built on @mahiframework/database's
after-commit dispatch.
The Mail facade
class Mail extends Facade<MailManager>(() => MAIL_TOKEN)
| Static | Forwards to |
|---|---|
Mail.send(mailable, options?) | MailManager.send() |
Mail.mailer(name?) | MailManager.mailer() |
Two methods. That's the whole facade.
There is no Mail.to(...). Laravel's
Mail::to($user)->cc($manager)->send(new InvoicePaid($invoice)) splits
"who receives this" between the call site and the mailable, and the two
can silently disagree, a mailable with its own to() plus a facade
to() gives you a message with recipients from both, or from one,
depending on Laravel's internals.
Here recipients live in exactly one place: the mailable's envelope() or
its to() calls. The call site's job is only "send it".
// ✅ this is the shape
await Mail.send(new WelcomeMailable(user));
await Mail.send(new WelcomeMailable(user), { mailer: "smtp" });
// ❌ does not exist
await Mail.to(user.email).send(new WelcomeMailable(user));
If a message's recipients genuinely vary per call, pass them into the constructor. That's what constructors are for:
export class ReportMailable extends Mailable {
constructor(private recipients: string[], private report: Report) {
super();
}
build(): void {
this.subject("Weekly report").html(renderReport(this.report));
for (const address of this.recipients) this.to(address);
}
}
Mail.mailer(name) returns the raw MailTransport, for when you have a
RenderedMail already or want to poke at an ArrayTransport in a test.
Prefer injecting MailManager via MAIL_TOKEN where you have app. The
facade resolves off the current global app, so a test with its own
isolated Application should resolve the token off that instance.
Sending mail from a queue
await Mail.queue(new WelcomeMailable(user));
await Mail.queue(new WelcomeMailable(user), { delaySeconds: 300, queue: "mail" });
Requires QueueServiceProvider; without it queue() throws a directive
error rather than silently sending.
Rendering happens at dispatch, not in the worker
queue() renders the mailable in the calling process and enqueues the
resulting RenderedMail. Laravel instead serializes the Mailable and
re-renders on the worker.
Rendering at dispatch is better on three counts:
- No second serialization mechanism. A
RenderedMailis already plain JSON. A queuedMailablewould need a name registry and a rehydration path of its own, duplicating whatJobRegistryalready does for jobs. - Validation fires at the call site. A missing subject is a 500 in your
controller, not a
failed_jobsrow at 3am. - The message cannot drift. Re-rendering on a worker reads rows that may have changed since dispatch, the source of "why did that email quote the old price".
The cost is payload size: the full HTML body rides in the job row, so a
50KB email is a 50KB row. For bulk sending, prefer a hand-written Job
carrying an id (below).
In-memory attachments ({ content: Buffer }) are rejected by
queue(), because JSON.stringify turns a Buffer into
{"0":137,"1":80,…}. Use { path } so the worker reads the file at send
time. It must still exist then. Base64-inlining is deliberately not done
for you: a 5MB PDF would become a ~6.7MB row, on every retry.
Never queue a message carrying a credential
A queued message sits in the jobs table in plaintext, and in
failed_jobs indefinitely if delivery fails. Anything in the body is
readable by anyone with database access for as long as the row lives.
So: no password-reset links, no magic links, no one-time codes, no invitation tokens.
This is not an argument against deferring those flows. It is an argument
against queueing the rendered message. Queue a job that carries an id,
mints the credential inside handle(), and sends immediately:
export class SendResetLink extends Job {
constructor(public readonly email: string) {
super();
}
async handle(): Promise<void> {
const result = await Auth.passwordBroker().sendResetLink(this.email);
if (result.token !== undefined) {
await Mail.send(new ResetPasswordMail(this.email, result.token, 60));
}
}
}
The token then exists only in the worker's memory. Note Mail.send(), not
Mail.queue(), queueing here would defeat the entire point.
The same reasoning is why the scaffolded auth controllers send synchronously; see Authentication.
A hand-written job is still the right tool sometimes
Write one instead of using Mail.queue() when the payload would be large (bulk
sends), when the message must reflect data as of delivery rather than
dispatch, or when a credential is involved:
export class SendWelcomeEmailJob extends Job {
constructor(public readonly userId: string) {
super();
}
async handle(): Promise<void> {
const user = await User.findOrFail(this.userId);
await Mail.send(new WelcomeMailable(user));
}
}
Carry an id, not the whole model. See Queues for job encoding, retries and backoff.
queue() versus afterCommit
They solve different problems and compose. afterCommit holds a send
until the enclosing transaction commits (and drops it on rollback);
queue() moves the send out of the request entirely. A mailable can use
both.
Sending mail from a notification
A Notification's toMail() returns a Mailable, which MailChannel
hands straight to MailManager.send(). If a message can plausibly also
land in a database or a websocket, model it as a notification rather than
a bare mailable. See Notifications.
Testing
Set mail.default to "array" and assert on captured messages.
import { Application } from "@mahiframework/core";
import { MailManager, MailServiceProvider, MAIL_TOKEN, ArrayTransport } from "@mahiframework/mail";
const app = new Application();
app.config.set("mail", { default: "array", mailers: { array: {} } });
app.register(MailServiceProvider);
await app.bootstrap();
const mail = app.make<MailManager>(MAIL_TOKEN);
const transport = mail.mailer() as ArrayTransport;
await mail.send(new WelcomeMailable(user));
expect(transport.messages).toHaveLength(1);
expect(transport.messages[0]).toMatchObject({
subject: "Welcome to Acme",
to: [{ address: "ada@example.com", name: "Ada Lovelace" }],
});
For a unit test of the message itself, skip the manager entirely and call
render():
const rendered = await new WelcomeMailable(user).render({ address: "hello@example.com" });
expect(rendered.from).toEqual({ address: "hello@example.com" });
Gotchas
Mail.to() does not exist. Recipients go on the mailable.
Overriding envelope() discards the fluent setters. to(),
subject(), tag() and friends mutate the instance the default
envelope() returns. Override the method and that instance is
unreachable. Same for content() versus view()/html()/text(), and
attachments() versus attach().
view() takes a function, not a template name. There is no template
system. view(() => renderIt()), never view("emails.welcome", data).
MailMessage is the exception. It has a body system, but it's blocks
plus a theme, still not a template name.
MailMessage.html()/text()/view()/textView() throw. They are
inherited from Mailable but unreachable, because MailMessage overrides
content() to render through its theme. They throw rather than silently
no-op: a discarded body would surface as a blank email in production.
Use line()/button()/panel(), or send a plain Mailable for a
hand-written body.
An unknown theme name throws. new MailMessage("typo") fails at
render, it does not fall back to the default theme.
Body thunks run on every render(). MailManager.send() calls
render() once per send; sending the same mailable twice re-renders it.
build() runs on every render() too. A build() that appends to
this._envelope.to will double the recipients on a second render.
Attachment content and path are not validated. Supply exactly one.
Mail.mailer(name).send(rendered) skips all validation. No CRLF
guard, no completeness check. Prefer Message for ad-hoc sends.
Mail.queue() writes the message body to the database in plaintext.
Never queue a reset link, magic link or one-time code, queue a job that
mints the credential in handle() instead.
Mail.queue() rejects in-memory attachments. Use { path }; the
worker reads the file at send time, so it must still exist then.
SentMessage means "accepted", not "delivered". A messageId from
SMTP tells you the server took it. Bounces happen later and are invisible
here.
ArrayTransport is cached by the manager. The same instance comes
back from every mailer("array"). Call flush() between tests.
The SMTP transport connects lazily. A bad host or bad credentials
surface on the first send(), not at boot. Nothing pings the server for
you.
config.from is a fallback, not a default-and-override. A mailable
with its own from() ignores it entirely.
Related
- Notifications:
toMail(), themailchannel - Queues:
Mail.queue(), job encoding, retries and backoff - Storage: reading attachment bytes off a disk
- Logging: where the
logmailer writes, and why it usesapp.logger - Configuration:
config/mail.ts, theMAIL_*/SMTP_*env vars - Providers: registering a custom transport via
extend() - Testing: the
arraymailer as the test fake