Mail

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

Implement the build() method and chain the fluent helpers:

src/mail/WelcomeMail.ts
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

TransportWhen to use it
LogTransportDevelopment — prints the full email to the console, nothing is sent
SmtpTransportProduction — sends via any SMTP provider (Postmark, SendGrid, Resend, SES, etc.)
SesTransportAWS SES — requires @aws-sdk/client-ses
ArrayTransportTesting — 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!,
    },
  })
)
.env
MAIL_HOST=smtp.postmarkapp.com
MAIL_PORT=587
MAIL_USER=your-api-token
MAIL_PASS=your-api-token

Attachments

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',
    })
}
Security — attachment fetching is off by default. 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 tests

Tip: 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)