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

···
build() {
  return this
    .sendTo(this.user.email)
    .subject('Your invoice')
    .html('<p>Please find your invoice attached.</p>')
    .attach({
      filename:    'invoice.pdf',
      path:        `/tmp/invoices/${this.invoiceId}.pdf`,
      contentType: 'application/pdf',
    })
}

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:

···
// ❌ Blocks the response — slow and fragile
await mailer.send(new WelcomeMail(user.email))

// ✅ Returns immediately — job runs in the background
const job = new SendWelcomeEmailJob()
job.userId = user.id
await queue.dispatch(job)