Skip to the content
OrielDocs
0.1.0-alpha.10GitHub

Digging deeper

Mail

Each kind of email your app sends is a mailable class, with a TSX view for its HTML. Oriel sends it over SMTP, writes it to the log, or queues it for a worker.

Introduction#

Oriel sends mail with Nodemailer, which comes with the framework. A new app writes every mail to its log instead of sending it, so you can work on your mail before you set up a mail server.

Configuration#

The mail settings are in config/mail.ts. Each entry of mailers is a mailer with its own transport, and default names the one your app uses:

config/mail.ts
import { env } from '@orieljs/framework'export default {  default: env('MAIL_MAILER', 'log'),  mailers: {    smtp: {      transport: 'smtp',      host: env('MAIL_HOST', '127.0.0.1'),      port: Number(env('MAIL_PORT', 2525)),      username: env('MAIL_USERNAME'),      password: env('MAIL_PASSWORD'),    },    log: { transport: 'log', channel: env('MAIL_LOG_CHANNEL') },    array: { transport: 'array' },    failover: { transport: 'failover', mailers: ['smtp', 'log'] },  },  from: { address: env('MAIL_FROM_ADDRESS', 'hello@example.com'), name: env('MAIL_FROM_NAME', 'Oriel') },}

Oriel has four transports:

  • smtp sends the mail to an SMTP server: host, port, and username and password when the server asks for them. Add encryption: 'ssl' for a server that speaks TLS from the start (usually port 465), or encryption: 'tls' to require STARTTLS.
  • log writes each mail to your log, through the channel channel names, or your default channel.
  • array keeps the mails in memory, in the app's process, and sends nothing.
  • failover tries the mailers of its mailers list in turn (see Failover configuration).

To send real mail, set MAIL_MAILER=smtp and the MAIL_* variables of your mail server in .env.

Failover configuration#

A failover mailer tries each mailer of its list, in order, until one sends the mail. The failover mailer of a new app tries smtp, then log, so a mail your SMTP server refuses still lands in your log. To use it, set MAIL_MAILER=failover.

When every mailer fails, sending throws an AllMailersFailedError that names them and carries the last error.

Generating mailables#

Each kind of email your app sends is a mailable class in app/Mail. make:mail writes one, with its view:

Terminal
node oriel make:mail OrderShipped

It writes app/Mail/OrderShipped.ts, and the view resources/views/mail/order-shipped.tsx, which the mailable names mail.order-shipped. --view=mail.orders.shipped gives the view another name, and so another file. A view that already exists is left alone. --force writes the class again over an existing one.

Writing mailables#

A mailable has three methods: envelope() gives the subject and, if you like, the sender and the recipients; content() names the view; attachments() lists the files to attach. Each may be async. Here is make:mail's mailable, without the line that queues it (see Queueing mail):

app/Mail/OrderShipped.ts
import { Mailable } from '@orieljs/framework'import type { Content, Envelope } from '@orieljs/framework/contracts'export class OrderShipped extends Mailable {  override async envelope(): Promise<Envelope> {    return { subject: 'Order Shipped' }  }  override async content(): Promise<Content> {    return { view: 'mail.order-shipped' }  }}

The envelope and the content are plain objects, typed by the Envelope and Content contracts.

Configuring the sender#

Using the envelope#

The envelope's from is the sender, and replyTo the addresses a reply goes to:

export class OrderShipped extends Mailable {  override async envelope(): Promise<Envelope> {    return {      from: { address: 'jeffrey@example.com', name: 'Jeffrey Way' },      replyTo: [{ address: 'taylor@example.com', name: 'Taylor Otwell' }],      subject: 'Order Shipped',    }  }}

An address is a string ('jeffrey@example.com'), an { address, name } object, or anything with an email and a name, such as a User.

Using a global from address#

A mailable that gives no from is sent from the from address of config/mail.ts. A new app reads it from MAIL_FROM_ADDRESS and MAIL_FROM_NAME, the second of which its .env sets to your app's name:

const mail = {  from: { address: env('MAIL_FROM_ADDRESS', 'hello@example.com'), name: env('MAIL_FROM_NAME', 'Oriel') },}

There is no global reply-to address: give replyTo in the envelope.

Configuring the view#

The content's view names the TSX view that renders the mail's HTML, with the dots and folders of every other view: mail.order-shipped is resources/views/mail/order-shipped.tsx.

export class OrderShipped extends Mailable {  override async content(): Promise<Content> {    return { view: 'mail.order-shipped' }  }}

A mail view is a view like any other, a component that returns the mail's HTML:

resources/views/mail/order-shipped.tsx
/** The OrderShipped mail's HTML. The mailable's `with` data arrives as props. */export default function OrderShipped() {  return (    <div>      <p>The body of your message.</p>    </div>  )}

Most mail clients don't load stylesheets, so a mail view keeps its styles inline, in style attributes.

Plain text emails#

textString gives the plain-text part of the mail, beside the HTML:

const content: Content = {  view: 'mail.order-shipped',  textString: `Your order ${order.name} has shipped.`,}

text names a view for the plain text instead. A TSX view escapes &, < and > as HTML does, so a text view returns its text through raw(), or use textString. Without a view, htmlString gives the HTML itself.

View data#

The content's with passes data to the view, which receives it as its props. Take the data in the mailable's constructor:

app/Mail/OrderShipped.ts
import { Mailable } from '@orieljs/framework'import type { Content, Envelope } from '@orieljs/framework/contracts'import type { Order } from '../Models/Order'export class OrderShipped extends Mailable {  constructor(public order: Order) {    super()  }  override async envelope(): Promise<Envelope> {    return { subject: 'Order Shipped' }  }  override async content(): Promise<Content> {    return { view: 'mail.order-shipped', with: { order: this.order } }  }}
resources/views/mail/order-shipped.tsx
import type { Order } from '../../../app/Models/Order'export default function OrderShipped({ order }: { order: Order }) {  return (    <div>      <p>Price: {order.price}</p>    </div>  )}

You can shape the data for the view first:

const content: Content = {  view: 'mail.order-shipped',  with: { orderName: order.name, orderPrice: order.price },}

Attachments#

attachments() returns the files to attach. Each has a name, the file name the recipient sees, and either a path to read it from or its data, with a mime type if you like:

export class OrderShipped extends Mailable {  override async attachments(): Promise<Attachment[]> {    return [      { name: 'invoice.pdf', path: '/path/to/invoice.pdf', mime: 'application/pdf' },      { name: 'notes.txt', data: 'Leave the parcel at the door.' },    ]  }}

data is a string or bytes. To attach a file from a storage disk, read its bytes there:

export class OrderShipped extends Mailable {  override async attachments(): Promise<Attachment[]> {    return [{ name: 'invoice.pdf', data: await Storage.disk('s3').getBytes('invoices/1.pdf') }]  }}

The log mailer leaves attachments out of the log.

Sending mail#

Mail.to() takes the recipients, and send() the mailable:

app/Http/Controllers/OrderShipmentController.ts
import { Mail, redirect, request } from '@orieljs/framework'import { OrderShipped } from '../../Mail/OrderShipped'import { Order } from '../../Models/Order'import type { User } from '../../Models/User'import { Controller } from './Controller'export class OrderShipmentController extends Controller {  async store() {    const order = await Order.findOrFail(request().integer('order_id'))    // Ship the order...    const user = (await request().user<User>())!    await Mail.to(user).send(new OrderShipped(order))    return redirect('/orders')  }}

The route to store() has the auth middleware, so the request has a user. Mail.to() takes an address, a user, or a list of them; a user gives its email and name. cc() and bcc() add more recipients:

await Mail.to(user).cc(moreUsers).bcc(evenMoreUsers).send(new OrderShipped(order))

A mailable may also name its recipients in its envelope's to, cc and bcc; Mail.send(mailable) then sends it to them. Mail.to() replaces the envelope's to, and cc() and bcc() add to the envelope's. With no recipient at all, sending throws a MissingRecipientError.

Looping over recipients#

Mail.to() doesn't change the mailable, so one mailable can go to each recipient of a list:

const mail = new OrderShipped(order)for (const recipient of ['taylor@example.com', 'dries@example.com']) {  await Mail.to(recipient).send(mail)}

Sending mail via a specific mailer#

Mail.mailer() sends through another mailer of config/mail.ts:

await Mail.mailer('smtp').to(user).send(new OrderShipped(order))

Queueing mail#

Queueing a mail message#

Sending mail over SMTP can take a second or more. queue() puts the mail on the queue instead, and a worker sends it in the background:

await Mail.to(user).queue(new OrderShipped(order))

The mailable travels to the worker with its fields; a model in a field travels as its key, and the worker reads it again from the database.

Delayed message queueing#

later() queues the mail to go after a number of seconds:

await Mail.to(user).later(10 * 60, new OrderShipped(order))

Laravel's later() also takes a date; Oriel's takes seconds.

Pushing to specific queues#

A mailable's connection and queue fields choose the queue connection and the queue its job goes on:

app/Mail/OrderShipped.ts
import { Mailable } from '@orieljs/framework'import type { Content, Envelope } from '@orieljs/framework/contracts'export class OrderShipped extends Mailable {  readonly connection = 'redis'  readonly queue = 'emails'  override async envelope(): Promise<Envelope> {    return { subject: 'Order Shipped' }  }  override async content(): Promise<Content> {    return { view: 'mail.order-shipped' }  }}

Its delay, tries, backoff and timeout fields go to the job too, and its failed(), retryUntil() and middleware() run as the job's.

Queueing by default#

A mailable that should always be queued says so with a field, readonly shouldQueue = true, as make:mail writes it. send() then queues it:

app/Mail/OrderShipped.ts
import { Mailable } from '@orieljs/framework'import type { Content, Envelope, ShouldQueue } from '@orieljs/framework/contracts'export class OrderShipped extends Mailable implements ShouldQueue {  /** Sent by the queue worker (node oriel queue:work): remove this line and `implements ShouldQueue` to send it right away. */  readonly shouldQueue = true  override async envelope(): Promise<Envelope> {    return { subject: 'Order Shipped' }  }  override async content(): Promise<Content> {    return { view: 'mail.order-shipped' }  }}

Sending a queued mailable now#

sendNow() sends a mail at once, in the current process, even when its mailable would be queued:

await Mail.to(user).sendNow(new OrderShipped(order))

Mail.sendNow() does the same for a mailable that names its recipients in its envelope.

Unique mailables#

A queued mailable with a uniqueId() method isn't queued again while the same mail is still on the queue: the second send() is dropped. The same mail means the same mailable class, the same uniqueId(), and the same recipients given with Mail.to(), cc() and bcc():

app/Mail/OrderShipped.ts
import { Mailable } from '@orieljs/framework'import type { Content, Envelope, ShouldQueue } from '@orieljs/framework/contracts'import type { Order } from '../Models/Order'export class OrderShipped extends Mailable implements ShouldQueue {  readonly shouldQueue = true  constructor(public order: Order) {    super()  }  uniqueId(): number {    return this.order.id  }  override async envelope(): Promise<Envelope> {    return { subject: 'Order Shipped' }  }  override async content(): Promise<Content> {    return { view: 'mail.order-shipped', with: { order: this.order } }  }}
await Mail.to('ann@example.com').send(new OrderShipped(order))await Mail.to('ann@example.com').send(new OrderShipped(order)) // dropped: the first is still queuedawait Mail.to('bo@example.com').send(new OrderShipped(order)) // queued: another recipient

The claim is a cache lock, so a unique mailable needs a working cache store: your default store, or the one that a uniqueVia() method names. It lasts until the mail is sent or has failed for good, or uniqueFor seconds at most when you set it; with uniqueUntilProcessing = true, it goes as soon as the worker starts on the mail. Unique jobs has the details.

Queued email failures#

When a queued mail has failed every attempt, the mailable's failed() method runs, with the error:

app/Mail/OrderDelayed.ts
import { Mailable } from '@orieljs/framework'import type { Content, Envelope, ShouldQueue } from '@orieljs/framework/contracts'export class OrderDelayed extends Mailable implements ShouldQueue {  readonly shouldQueue = true  override async envelope(): Promise<Envelope> {    return { subject: 'Order Delayed' }  }  override async content(): Promise<Content> {    return { view: 'mail.order-delayed' }  }  async failed(error: unknown): Promise<void> {    // ...  }}

The failed job is stored with the mailable's class name, as queue:failed shows it.

Rendering mailables#

renderContent() renders a mailable's content without sending it, and returns its html and text:

const { html, text } = await renderContent(await new OrderShipped(order).content())

Previewing mailables in the browser#

To look at a mail while you design it, return its HTML from a route. A string is sent as an HTML page:

routes/web.ts
import { renderContent, Route } from '@orieljs/framework'import { OrderShipped } from '../app/Mail/OrderShipped'import { Order } from '../app/Models/Order'Route.get('/mailable', async () => {  const order = await Order.findOrFail(1)  const { html } = await renderContent(await new OrderShipped(order).content())  return html})

Testing#

Mail.fake() swaps the mailer for a fake that sends nothing and records each mail. Its assertions check what your code sent or queued:

tests/Feature/OrderShipment.test.ts
import { Mail } from '@orieljs/framework'import { test } from 'vitest'import { OrderShipped } from '../../app/Mail/OrderShipped'import { TestCase } from '../TestCase'const t = TestCase.boot()test('orders can be shipped', async () => {  const mail = Mail.fake()  await t.post('/orders/1/shipment')  mail.assertSent(OrderShipped, (mailable, record) => record.hasTo('taylor@example.com'))  mail.assertSent(OrderShipped, 1)  mail.assertNotQueued(OrderShipped)})

A mailable that would be queued counts as queued, not sent: check it with assertQueued(). The fake also has assertNotSent(), assertNothingSent(), assertSentCount(), assertNothingQueued(), assertQueuedCount(), assertOutgoingCount() and assertNothingOutgoing(), and sent() and queued() return the mailables. Each record has hasTo(), hasCc(), hasBcc() and hasSubject(). Nothing is rendered, so the fake doesn't check your views.

Mail and local development#

A new app's MAIL_MAILER is log, so every mail is written to storage/logs/oriel.log, with its recipients, its subject and its text (or its HTML without the tags). With MAIL_LOG_CHANNEL=mail, the mails go to storage/logs/mail.log instead, the mail channel of config/logging.ts. The links in your mails, such as a password reset link, are in there.

To see your mails as a mail client shows them, run a local SMTP server that catches mail, such as Mailpit, and set MAIL_MAILER=smtp with its host and port.

Custom transports#

A transport is a class with a send() method, which receives the message, with its addresses, subject, HTML, text and attachments, and returns the sent message's id:

app/Mail/Transports/HttpApiTransport.ts
import { randomUUID } from 'node:crypto'import type { MailTransport, OutgoingMessage, SentMessage } from '@orieljs/framework/contracts'export class HttpApiTransport implements MailTransport {  constructor(private readonly key: string) {}  async send(message: OutgoingMessage): Promise<SentMessage> {    // Send message.from, message.to, message.subject, message.html, ... with the service's API.    return { messageId: randomUUID(), message }  }}

Register it with Mail.extend() in a service provider's boot(). The callback receives the mailer's entry of config/mail.ts:

app/Providers/AppServiceProvider.ts
import { Mail, ServiceProvider } from '@orieljs/framework'import { HttpApiTransport } from '../Mail/Transports/HttpApiTransport'export class AppServiceProvider extends ServiceProvider {  override boot(): void {    Mail.extend('http-api', (config) => new HttpApiTransport(String(config.key)))  }}

Then add a mailer that uses it to config/mail.ts: api: { transport: 'http-api', key: env('MAIL_API_KEY') }.

Privacy policy ·

esc