Pearl's mail system is built around Mailable classes. Instead of calling a mailer function directly with a pile of arguments, you create a class that represents one specific email — its subject, recipient, and body — and send it with a Mailer.
This makes emails easy to test and easy to reuse across your app. Requires nodemailer v7+ — types are bundled, so @types/nodemailer is not needed.
Create a Mailable
pearl make:mail WelcomeMail
# → src/mail/WelcomeMail.tsImplement the build() method and chain the fluent helpers:
import { Mailable } from '@pearl-framework/pearl'
export class WelcomeMail extends Mailable {
constructor(private readonly user: User) {
super()
}
build() {
return this
.sendTo(this.user.email)
.from({ name: 'My App', address: 'hi@myapp.com' })
.subject(`Welcome to My App, ${this.user.name}!`)
.html(`
<h1>Hi ${this.user.name}, welcome aboard.</h1>
<p>Thanks for signing up. You're all set to get started.</p>
<a href="https://myapp.com/dashboard">Go to your dashboard</a>
`)
.text(`Hi ${this.user.name}, welcome! Visit https://myapp.com/dashboard`)
}
}Sending mail
import { Mailer, LogTransport } from '@pearl-framework/pearl'
import { WelcomeMail } from '../mail/WelcomeMail.js'
const mailer = new Mailer(new LogTransport()) // logs to console — great for dev
await mailer.send(new WelcomeMail(user))Transports
| Transport | When to use it |
|---|---|
LogTransport | Development — prints the full email to the console, nothing is sent |
SmtpTransport | Production — sends via any SMTP provider (Postmark, SendGrid, Resend, SES, etc.) |
SesTransport | AWS SES — requires @aws-sdk/client-ses |
ArrayTransport | Testing — captures sent mail in memory so you can assert against it |
SMTP transport (production)
import { Mailer, SmtpTransport } from '@pearl-framework/pearl'
const mailer = new Mailer(
new SmtpTransport({
host: process.env.MAIL_HOST!,
port: Number(process.env.MAIL_PORT ?? 587),
secure: false,
auth: {
user: process.env.MAIL_USER!,
pass: process.env.MAIL_PASS!,
},
})
)MAIL_HOST=smtp.postmarkapp.com
MAIL_PORT=587
MAIL_USER=your-api-token
MAIL_PASS=your-api-tokenAttachments
Read the file yourself and pass the bytes via content. The SMTP transport refuses path-based and URL-based attachments by default (see the security note below).
import { readFile } from 'node:fs/promises'
async build() {
return this
.sendTo(this.user.email)
.subject('Your invoice')
.html('<p>Please find your invoice attached.</p>')
.attach({
filename: 'invoice.pdf',
content: await readFile(`/tmp/invoices/${this.invoiceId}.pdf`),
contentType: 'application/pdf',
})
}SmtpTransport configures nodemailer with disableFileAccess: true and disableUrlAccess: true, so passing path or href on an attachment will fail. This prevents an attacker who controls part of an attachment from reading arbitrary local files or triggering SSRF against internal endpoints. If you genuinely need path/URL fetching, build a custom transport that wraps nodemailer without these flags.Sending in bulk
Use sendBulk when you have many mailables to send. It runs sends in parallel with a bounded concurrency so you don't exhaust SMTP connections. The default cap is 10 — override via the concurrency option, or set defaultBulkConcurrency on your Mailer.
// Fail fast on the first error (default)
await mailer.sendBulk(mailables, { concurrency: 20 })
// Collect errors instead of aborting
const result = await mailer.sendBulk(mailables, {
concurrency: 20,
continueOnError: true,
})
console.log(`Sent ${result.sent} mails, ${result.errors.length} failures`)
result.errors.forEach(({ index, error }) =>
console.error(`#${index} failed:`, error)
)Testing with ArrayTransport
Swap in ArrayTransport in tests to capture sent mail in memory and assert against it — no SMTP server needed:
import { ArrayTransport, Mailer } from '@pearl-framework/pearl'
const transport = new ArrayTransport()
const mailer = new Mailer(transport)
await mailer.send(new WelcomeMail(user))
const sent = transport.last()
assert.equal(sent?.subject, `Welcome to My App, ${user.name}!`)
assert.equal(transport.sent.length, 1)
transport.clear() // reset between testsTip: always send mail via a queue
SMTP calls can be slow and fail. Instead of sending in your route handler, dispatch a queue job to send in the background:
// Avoid: blocks the response — slow and fragile
await mailer.send(new WelcomeMail(user.email))
// Prefer: returns immediately — job runs in the background
const job = new SendWelcomeEmailJob()
job.userId = user.id
await queue.dispatch(job)