@orieljs/framework 0.1.0-alpha.5 → 0.1.0-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/NOTICE +7 -0
  2. package/README.md +10 -1
  3. package/TRADEMARKS.md +33 -0
  4. package/dist/{chunk-WNJP46B7.js → chunk-4MWG3B7G.js} +1785 -626
  5. package/dist/chunk-4MWG3B7G.js.map +1 -0
  6. package/dist/chunk-7ZQZYDOC.js +3253 -0
  7. package/dist/chunk-7ZQZYDOC.js.map +1 -0
  8. package/dist/{chunk-BSQKOLSK.js → chunk-GVNLZZTN.js} +1 -1
  9. package/dist/chunk-GVNLZZTN.js.map +1 -0
  10. package/dist/{chunk-A5XRLEWM.js → chunk-VOYW3R5J.js} +4 -4
  11. package/dist/chunk-VOYW3R5J.js.map +1 -0
  12. package/dist/contracts.d.ts +535 -17
  13. package/dist/contracts.js +1 -1
  14. package/dist/index.d-WQpfdIbG.d.ts +630 -0
  15. package/dist/index.d.ts +2202 -5952
  16. package/dist/index.js +4900 -944
  17. package/dist/index.js.map +1 -1
  18. package/dist/{jsx-runtime-WKTl3EEw.d.ts → jsx-runtime-6um4Mwfb.d.ts} +97 -51
  19. package/dist/jsx-runtime.d.ts +2 -2
  20. package/dist/jsx-runtime.js +3 -3
  21. package/dist/{testing-BErengYY.d.ts → testing-CKwfWJGn.d.ts} +313 -994
  22. package/dist/testing.d.ts +2 -2
  23. package/dist/testing.js +19 -5
  24. package/docs/authentication.md +487 -0
  25. package/docs/authorization.md +435 -0
  26. package/docs/cache.md +647 -0
  27. package/docs/collections.md +2270 -0
  28. package/docs/configuration.md +334 -0
  29. package/docs/console.md +651 -0
  30. package/docs/container.md +430 -0
  31. package/docs/controllers.md +406 -0
  32. package/docs/csrf.md +126 -0
  33. package/docs/database-testing.md +144 -0
  34. package/docs/database.md +236 -0
  35. package/docs/deployment.md +276 -0
  36. package/docs/eloquent-collections.md +218 -0
  37. package/docs/eloquent-factories.md +255 -0
  38. package/docs/eloquent-mutators.md +254 -0
  39. package/docs/eloquent-observers.md +145 -0
  40. package/docs/eloquent-relationships.md +557 -0
  41. package/docs/eloquent.md +494 -0
  42. package/docs/encryption.md +104 -0
  43. package/docs/errors.md +318 -0
  44. package/docs/events.md +336 -0
  45. package/docs/facades.md +278 -0
  46. package/docs/hashing.md +118 -0
  47. package/docs/helpers.md +1281 -0
  48. package/docs/http-tests.md +386 -0
  49. package/docs/inertia.md +485 -0
  50. package/docs/installation.md +209 -0
  51. package/docs/lifecycle.md +234 -0
  52. package/docs/live-tests.md +226 -0
  53. package/docs/live.md +910 -0
  54. package/docs/logging.md +255 -0
  55. package/docs/mail.md +619 -0
  56. package/docs/middleware.md +384 -0
  57. package/docs/migrations.md +416 -0
  58. package/docs/mocking.md +264 -0
  59. package/docs/navigation.json +273 -0
  60. package/docs/notifications.md +225 -0
  61. package/docs/pagination.md +247 -0
  62. package/docs/passwords.md +207 -0
  63. package/docs/providers.md +187 -0
  64. package/docs/queries.md +600 -0
  65. package/docs/queues.md +330 -0
  66. package/docs/requests.md +533 -0
  67. package/docs/responses.md +444 -0
  68. package/docs/routing.md +738 -0
  69. package/docs/scheduling.md +277 -0
  70. package/docs/seeding.md +122 -0
  71. package/docs/session.md +409 -0
  72. package/docs/starter-kits.md +396 -0
  73. package/docs/storage.md +192 -0
  74. package/docs/structure.md +264 -0
  75. package/docs/testing.md +165 -0
  76. package/docs/time-travel.md +138 -0
  77. package/docs/validation.md +1792 -0
  78. package/docs/verification.md +152 -0
  79. package/docs/views.md +566 -0
  80. package/docs/vite.md +322 -0
  81. package/package.json +34 -32
  82. package/dist/chunk-A5XRLEWM.js.map +0 -1
  83. package/dist/chunk-BSQKOLSK.js.map +0 -1
  84. package/dist/chunk-PM5YTG57.js +0 -880
  85. package/dist/chunk-PM5YTG57.js.map +0 -1
  86. package/dist/chunk-WNJP46B7.js.map +0 -1
  87. package/dist/index.d-DOlTeG-8.d.ts +0 -99
package/docs/mail.md ADDED
@@ -0,0 +1,619 @@
1
+ # Mail
2
+
3
+ Each kind of email your app sends is a mailable class, with a TSX view for its HTML. Oriel sends it over SMTP, writes
4
+ it to the log, or queues it for a worker.
5
+
6
+ ## Introduction
7
+
8
+ Oriel sends mail with [Nodemailer](https://nodemailer.com), which comes with the framework. A new app writes every
9
+ mail to its log instead of sending it, so you can work on your mail before you set up a mail server.
10
+
11
+ ### Configuration
12
+
13
+ The mail settings are in `config/mail.ts`. Each entry of `mailers` is a mailer with its own transport, and `default`
14
+ names the one your app uses:
15
+
16
+ ```ts title="config/mail.ts"
17
+ import { env } from '@orieljs/framework'
18
+
19
+ export default {
20
+ default: env('MAIL_MAILER', 'log'),
21
+ mailers: {
22
+ smtp: {
23
+ transport: 'smtp',
24
+ host: env('MAIL_HOST', '127.0.0.1'),
25
+ port: Number(env('MAIL_PORT', 2525)),
26
+ username: env('MAIL_USERNAME'),
27
+ password: env('MAIL_PASSWORD'),
28
+ },
29
+ log: { transport: 'log', channel: env('MAIL_LOG_CHANNEL') },
30
+ array: { transport: 'array' },
31
+ failover: { transport: 'failover', mailers: ['smtp', 'log'] },
32
+ },
33
+ from: { address: env('MAIL_FROM_ADDRESS', 'hello@example.com'), name: env('MAIL_FROM_NAME', 'Oriel') },
34
+ }
35
+ ```
36
+
37
+ Oriel has four transports:
38
+
39
+ - `smtp` sends the mail to an SMTP server: `host`, `port`, and `username` and `password` when the server asks for
40
+ them. Add `encryption: 'ssl'` for a server that speaks TLS from the start (usually port 465), or
41
+ `encryption: 'tls'` to require STARTTLS.
42
+ - `log` writes each mail to your log, through the channel `channel` names, or your default channel.
43
+ - `array` keeps the mails in memory, in the app's process, and sends nothing.
44
+ - `failover` tries the mailers of its `mailers` list in turn (see [Failover configuration](#failover-configuration)).
45
+
46
+ To send real mail, set `MAIL_MAILER=smtp` and the `MAIL_*` variables of your mail server in `.env`.
47
+
48
+ > [!NOTE]
49
+ > Laravel also has transports for Mailgun, Postmark, Resend, Amazon SES and `sendmail`, and a `roundrobin` mailer.
50
+ > Oriel has `smtp`, `log`, `array` and `failover`. Most mail services accept SMTP, and you can add a transport of your
51
+ > own: see [Custom transports](#custom-transports).
52
+
53
+ ### Failover configuration
54
+
55
+ A failover mailer tries each mailer of its list, in order, until one sends the mail. The `failover` mailer of a new
56
+ app tries `smtp`, then `log`, so a mail your SMTP server refuses still lands in your log. To use it, set
57
+ `MAIL_MAILER=failover`.
58
+
59
+ When every mailer fails, sending throws an `AllMailersFailedError` that names them and carries the last error.
60
+
61
+ ## Generating mailables
62
+
63
+ Each kind of email your app sends is a mailable class in `app/Mail`. `make:mail` writes one, with its view:
64
+
65
+ ```sh
66
+ node oriel make:mail OrderShipped
67
+ ```
68
+
69
+ It writes `app/Mail/OrderShipped.ts`, and the view `resources/views/mail/order-shipped.tsx`, which the mailable
70
+ names `mail.order-shipped`. `--view=mail.orders.shipped` gives the view another name, and so another file. A view
71
+ that already exists is left alone. `--force` writes the class again over an existing one.
72
+
73
+ ## Writing mailables
74
+
75
+ A mailable has three methods: `envelope()` gives the subject and, if you like, the sender and the recipients;
76
+ `content()` names the view; `attachments()` lists the files to attach. Each may be async. Here is `make:mail`'s
77
+ mailable, without the line that queues it (see [Queueing mail](#queueing-mail)):
78
+
79
+ ```ts title="app/Mail/OrderShipped.ts"
80
+ import { Mailable } from '@orieljs/framework'
81
+ import type { Content, Envelope } from '@orieljs/framework/contracts'
82
+
83
+ export class OrderShipped extends Mailable {
84
+ override async envelope(): Promise<Envelope> {
85
+ return { subject: 'Order Shipped' }
86
+ }
87
+
88
+ override async content(): Promise<Content> {
89
+ return { view: 'mail.order-shipped' }
90
+ }
91
+ }
92
+ ```
93
+
94
+ The envelope and the content are plain objects, typed by the `Envelope` and `Content` contracts.
95
+
96
+ ### Configuring the sender
97
+
98
+ #### Using the envelope
99
+
100
+ The envelope's `from` is the sender, and `replyTo` the addresses a reply goes to:
101
+
102
+ ```ts
103
+ export class OrderShipped extends Mailable {
104
+ override async envelope(): Promise<Envelope> {
105
+ return {
106
+ from: { address: 'jeffrey@example.com', name: 'Jeffrey Way' },
107
+ replyTo: [{ address: 'taylor@example.com', name: 'Taylor Otwell' }],
108
+ subject: 'Order Shipped',
109
+ }
110
+ }
111
+ }
112
+ ```
113
+
114
+ An address is a string (`'jeffrey@example.com'`), an `{ address, name }` object, or anything with an `email` and a
115
+ `name`, such as a `User`.
116
+
117
+ #### Using a global `from` address
118
+
119
+ A mailable that gives no `from` is sent from the `from` address of `config/mail.ts`. A new app reads it from
120
+ `MAIL_FROM_ADDRESS` and `MAIL_FROM_NAME`, the second of which its `.env` sets to your app's name:
121
+
122
+ ```ts
123
+ const mail = {
124
+ from: { address: env('MAIL_FROM_ADDRESS', 'hello@example.com'), name: env('MAIL_FROM_NAME', 'Oriel') },
125
+ }
126
+ ```
127
+
128
+ There is no global reply-to address: give `replyTo` in the envelope.
129
+
130
+ ### Configuring the view
131
+
132
+ The content's `view` names the TSX view that renders the mail's HTML, with the dots and folders of every other view:
133
+ `mail.order-shipped` is `resources/views/mail/order-shipped.tsx`.
134
+
135
+ ```ts
136
+ export class OrderShipped extends Mailable {
137
+ override async content(): Promise<Content> {
138
+ return { view: 'mail.order-shipped' }
139
+ }
140
+ }
141
+ ```
142
+
143
+ A mail view is a [view](views.md) like any other, a component that returns the mail's HTML:
144
+
145
+ ```tsx title="resources/views/mail/order-shipped.tsx"
146
+ /** The OrderShipped mail's HTML. The mailable's `with` data arrives as props. */
147
+ export default function OrderShipped() {
148
+ return (
149
+ <div>
150
+ <p>The body of your message.</p>
151
+ </div>
152
+ )
153
+ }
154
+ ```
155
+
156
+ Most mail clients don't load stylesheets, so a mail view keeps its styles inline, in `style` attributes.
157
+
158
+ #### Plain text emails
159
+
160
+ `textString` gives the plain-text part of the mail, beside the HTML:
161
+
162
+ ```ts
163
+ declare const order: Order
164
+ // ---cut---
165
+ const content: Content = {
166
+ view: 'mail.order-shipped',
167
+ textString: `Your order ${order.name} has shipped.`,
168
+ }
169
+ ```
170
+
171
+ `text` names a view for the plain text instead. A TSX view escapes `&`, `<` and `>` as HTML does, so a text view
172
+ returns its text through [`raw()`](helpers.md#raw), or use `textString`. Without a `view`, `htmlString` gives the HTML
173
+ itself.
174
+
175
+ ### View data
176
+
177
+ The content's `with` passes data to the view, which receives it as its props. Take the data in the mailable's
178
+ constructor:
179
+
180
+ ```ts title="app/Mail/OrderShipped.ts"
181
+ import { Mailable } from '@orieljs/framework'
182
+ import type { Content, Envelope } from '@orieljs/framework/contracts'
183
+ import type { Order } from '../Models/Order'
184
+
185
+ export class OrderShipped extends Mailable {
186
+ constructor(public order: Order) {
187
+ super()
188
+ }
189
+
190
+ override async envelope(): Promise<Envelope> {
191
+ return { subject: 'Order Shipped' }
192
+ }
193
+
194
+ override async content(): Promise<Content> {
195
+ return { view: 'mail.order-shipped', with: { order: this.order } }
196
+ }
197
+ }
198
+ ```
199
+
200
+ ```tsx title="resources/views/mail/order-shipped.tsx"
201
+ import type { Order } from '../../../app/Models/Order'
202
+
203
+ export default function OrderShipped({ order }: { order: Order }) {
204
+ return (
205
+ <div>
206
+ <p>Price: {order.price}</p>
207
+ </div>
208
+ )
209
+ }
210
+ ```
211
+
212
+ You can shape the data for the view first:
213
+
214
+ ```ts
215
+ declare const order: Order
216
+ // ---cut---
217
+ const content: Content = {
218
+ view: 'mail.order-shipped',
219
+ with: { orderName: order.name, orderPrice: order.price },
220
+ }
221
+ ```
222
+
223
+ > [!NOTE]
224
+ > Laravel passes a mailable's public properties to its view without being asked. Oriel passes only what `with`
225
+ > holds, so the view's props are the ones you list.
226
+
227
+ ### Attachments
228
+
229
+ `attachments()` returns the files to attach. Each has a `name`, the file name the recipient sees, and either a `path`
230
+ to read it from or its `data`, with a `mime` type if you like:
231
+
232
+ ```ts
233
+ export class OrderShipped extends Mailable {
234
+ override async attachments(): Promise<Attachment[]> {
235
+ return [
236
+ { name: 'invoice.pdf', path: '/path/to/invoice.pdf', mime: 'application/pdf' },
237
+ { name: 'notes.txt', data: 'Leave the parcel at the door.' },
238
+ ]
239
+ }
240
+ }
241
+ ```
242
+
243
+ `data` is a string or bytes. To attach a file from a storage disk, read its bytes there:
244
+
245
+ ```ts
246
+ export class OrderShipped extends Mailable {
247
+ override async attachments(): Promise<Attachment[]> {
248
+ return [{ name: 'invoice.pdf', data: await Storage.disk('s3').getBytes('invoices/1.pdf') }]
249
+ }
250
+ }
251
+ ```
252
+
253
+ The `log` mailer leaves attachments out of the log.
254
+
255
+ > [!NOTE]
256
+ > Laravel builds attachments with `Attachment::fromPath()` and its other methods, and also has inline attachments,
257
+ > attachable objects and custom headers. Oriel's attachments are plain objects, and a mailable has no inline
258
+ > attachments and no headers. The envelope's `tags` and `metadata` are typed, but no transport sends them.
259
+
260
+ ## Sending mail
261
+
262
+ `Mail.to()` takes the recipients, and `send()` the mailable:
263
+
264
+ ```ts title="app/Http/Controllers/OrderShipmentController.ts"
265
+ import { Mail, redirect, request } from '@orieljs/framework'
266
+ import { OrderShipped } from '../../Mail/OrderShipped'
267
+ import { Order } from '../../Models/Order'
268
+ import type { User } from '../../Models/User'
269
+ import { Controller } from './Controller'
270
+
271
+ export class OrderShipmentController extends Controller {
272
+ async store() {
273
+ const order = await Order.findOrFail(request().integer('order_id'))
274
+
275
+ // Ship the order...
276
+
277
+ const user = (await request().user<User>())!
278
+ await Mail.to(user).send(new OrderShipped(order))
279
+
280
+ return redirect('/orders')
281
+ }
282
+ }
283
+ ```
284
+
285
+ The route to `store()` has the `auth` middleware, so the request has a user. `Mail.to()` takes an address, a user, or
286
+ a list of them; a user gives its `email` and `name`. `cc()` and `bcc()` add more recipients:
287
+
288
+ ```ts
289
+ declare const user: User
290
+ declare const moreUsers: User[]
291
+ declare const evenMoreUsers: User[]
292
+ declare const order: Order
293
+ // ---cut---
294
+ await Mail.to(user).cc(moreUsers).bcc(evenMoreUsers).send(new OrderShipped(order))
295
+ ```
296
+
297
+ A mailable may also name its recipients in its envelope's `to`, `cc` and `bcc`; `Mail.send(mailable)` then sends it
298
+ to them. `Mail.to()` replaces the envelope's `to`, and `cc()` and `bcc()` add to the envelope's. With no recipient at
299
+ all, sending throws a `MissingRecipientError`.
300
+
301
+ #### Looping over recipients
302
+
303
+ `Mail.to()` doesn't change the mailable, so one mailable can go to each recipient of a list:
304
+
305
+ ```ts
306
+ declare const order: Order
307
+ // ---cut---
308
+ const mail = new OrderShipped(order)
309
+
310
+ for (const recipient of ['taylor@example.com', 'dries@example.com']) {
311
+ await Mail.to(recipient).send(mail)
312
+ }
313
+ ```
314
+
315
+ #### Sending mail via a specific mailer
316
+
317
+ `Mail.mailer()` sends through another mailer of `config/mail.ts`:
318
+
319
+ ```ts
320
+ declare const user: User
321
+ declare const order: Order
322
+ // ---cut---
323
+ await Mail.mailer('smtp').to(user).send(new OrderShipped(order))
324
+ ```
325
+
326
+ ### Queueing mail
327
+
328
+ #### Queueing a mail message
329
+
330
+ Sending mail over SMTP can take a second or more. `queue()` puts the mail on the [queue](queues.md) instead, and a
331
+ worker sends it in the background:
332
+
333
+ ```ts
334
+ declare const user: User
335
+ declare const order: Order
336
+ // ---cut---
337
+ await Mail.to(user).queue(new OrderShipped(order))
338
+ ```
339
+
340
+ The mailable travels to the worker with its fields; a model in a field travels as its key, and the worker reads it
341
+ again from the database.
342
+
343
+ #### Delayed message queueing
344
+
345
+ `later()` queues the mail to go after a number of seconds:
346
+
347
+ ```ts
348
+ declare const user: User
349
+ declare const order: Order
350
+ // ---cut---
351
+ await Mail.to(user).later(10 * 60, new OrderShipped(order))
352
+ ```
353
+
354
+ Laravel's `later()` also takes a date; Oriel's takes seconds.
355
+
356
+ #### Pushing to specific queues
357
+
358
+ A mailable's `connection` and `queue` fields choose the queue connection and the queue its job goes on:
359
+
360
+ ```ts title="app/Mail/OrderShipped.ts"
361
+ import { Mailable } from '@orieljs/framework'
362
+ import type { Content, Envelope } from '@orieljs/framework/contracts'
363
+
364
+ export class OrderShipped extends Mailable {
365
+ readonly connection = 'redis'
366
+ readonly queue = 'emails'
367
+
368
+ override async envelope(): Promise<Envelope> {
369
+ return { subject: 'Order Shipped' }
370
+ }
371
+
372
+ override async content(): Promise<Content> {
373
+ return { view: 'mail.order-shipped' }
374
+ }
375
+ }
376
+ ```
377
+
378
+ Its `delay`, `tries`, `backoff` and `timeout` fields go to the job too, and its `failed()`, `retryUntil()` and
379
+ `middleware()` run as the job's.
380
+
381
+ #### Queueing by default
382
+
383
+ A mailable that should always be queued says so with a field, `readonly shouldQueue = true`, as `make:mail` writes
384
+ it. `send()` then queues it:
385
+
386
+ ```ts title="app/Mail/OrderShipped.ts"
387
+ import { Mailable } from '@orieljs/framework'
388
+ import type { Content, Envelope, ShouldQueue } from '@orieljs/framework/contracts'
389
+
390
+ export class OrderShipped extends Mailable implements ShouldQueue {
391
+ /** Sent by the queue worker (node oriel queue:work): remove this line and `implements ShouldQueue` to send it right away. */
392
+ readonly shouldQueue = true
393
+
394
+ override async envelope(): Promise<Envelope> {
395
+ return { subject: 'Order Shipped' }
396
+ }
397
+
398
+ override async content(): Promise<Content> {
399
+ return { view: 'mail.order-shipped' }
400
+ }
401
+ }
402
+ ```
403
+
404
+ > [!NOTE]
405
+ > In Laravel, implementing the `ShouldQueue` interface queues a mailable. TypeScript interfaces are gone when your
406
+ > app runs, so Oriel looks for the `shouldQueue` field instead; `implements ShouldQueue` makes TypeScript ask for it.
407
+ > A mailable is also queued when it sets any of the queue fields above (`connection`, `queue`, `delay`, `tries`,
408
+ > `backoff`, `timeout`) or has a `uniqueId()` or `retryUntil()` method.
409
+
410
+ #### Sending a queued mailable now
411
+
412
+ `sendNow()` sends a mail at once, in the current process, even when its mailable would be queued:
413
+
414
+ ```ts
415
+ declare const user: User
416
+ declare const order: Order
417
+ // ---cut---
418
+ await Mail.to(user).sendNow(new OrderShipped(order))
419
+ ```
420
+
421
+ `Mail.sendNow()` does the same for a mailable that names its recipients in its envelope.
422
+
423
+ #### Unique mailables
424
+
425
+ A queued mailable with a `uniqueId()` method isn't queued again while the same mail is still on the queue: the
426
+ second `send()` is dropped. The same mail means the same mailable class, the same `uniqueId()`, and the same
427
+ recipients given with `Mail.to()`, `cc()` and `bcc()`:
428
+
429
+ ```ts title="app/Mail/OrderShipped.ts"
430
+ import { Mailable } from '@orieljs/framework'
431
+ import type { Content, Envelope, ShouldQueue } from '@orieljs/framework/contracts'
432
+ import type { Order } from '../Models/Order'
433
+
434
+ export class OrderShipped extends Mailable implements ShouldQueue {
435
+ readonly shouldQueue = true
436
+
437
+ constructor(public order: Order) {
438
+ super()
439
+ }
440
+
441
+ uniqueId(): number {
442
+ return this.order.id
443
+ }
444
+
445
+ override async envelope(): Promise<Envelope> {
446
+ return { subject: 'Order Shipped' }
447
+ }
448
+
449
+ override async content(): Promise<Content> {
450
+ return { view: 'mail.order-shipped', with: { order: this.order } }
451
+ }
452
+ }
453
+ ```
454
+
455
+ ```ts
456
+ declare const order: Order
457
+ // ---cut---
458
+ await Mail.to('ann@example.com').send(new OrderShipped(order))
459
+ await Mail.to('ann@example.com').send(new OrderShipped(order)) // dropped: the first is still queued
460
+ await Mail.to('bo@example.com').send(new OrderShipped(order)) // queued: another recipient
461
+ ```
462
+
463
+ The claim is a cache lock, so a unique mailable needs a working cache store: your default store, or the one that a
464
+ `uniqueVia()` method names. It lasts until the mail is sent or has failed for good, or `uniqueFor` seconds at most when
465
+ you set it; with `uniqueUntilProcessing = true`, it goes as soon as the worker starts on the mail. [Unique
466
+ jobs](queues.md#unique-jobs) has the details.
467
+
468
+ > [!WARNING]
469
+ > A mailable that names its recipients only in its envelope is a duplicate by its `uniqueId()` alone: the same order
470
+ > mailed to another customer is dropped. Put the recipient in the `uniqueId()`, as
471
+ > `` `${this.order.id}:${this.order.user_id}` ``, or address the mail with `Mail.to()`.
472
+
473
+ > [!NOTE]
474
+ > Laravel puts a queued mailable straight on the queue, so `ShouldBeUnique` has no effect on it. Oriel's queued
475
+ > mailables take the unique lock as jobs do, and a duplicate counts only for the same recipients.
476
+
477
+ #### Queued email failures
478
+
479
+ When a queued mail has failed every attempt, the mailable's `failed()` method runs, with the error:
480
+
481
+ ```ts title="app/Mail/OrderDelayed.ts"
482
+ import { Mailable } from '@orieljs/framework'
483
+ import type { Content, Envelope, ShouldQueue } from '@orieljs/framework/contracts'
484
+
485
+ export class OrderDelayed extends Mailable implements ShouldQueue {
486
+ readonly shouldQueue = true
487
+
488
+ override async envelope(): Promise<Envelope> {
489
+ return { subject: 'Order Delayed' }
490
+ }
491
+
492
+ override async content(): Promise<Content> {
493
+ return { view: 'mail.order-delayed' }
494
+ }
495
+
496
+ async failed(error: unknown): Promise<void> {
497
+ // ...
498
+ }
499
+ }
500
+ ```
501
+
502
+ The failed job is stored with the mailable's class name, as `queue:failed` shows it.
503
+
504
+ ## Rendering mailables
505
+
506
+ `renderContent()` renders a mailable's content without sending it, and returns its `html` and `text`:
507
+
508
+ ```ts
509
+ declare const order: Order
510
+ // ---cut---
511
+ const { html, text } = await renderContent(await new OrderShipped(order).content())
512
+ ```
513
+
514
+ ### Previewing mailables in the browser
515
+
516
+ To look at a mail while you design it, return its HTML from a route. A string is sent as an HTML page:
517
+
518
+ ```ts title="routes/web.ts"
519
+ import { renderContent, Route } from '@orieljs/framework'
520
+ import { OrderShipped } from '../app/Mail/OrderShipped'
521
+ import { Order } from '../app/Models/Order'
522
+
523
+ Route.get('/mailable', async () => {
524
+ const order = await Order.findOrFail(1)
525
+ const { html } = await renderContent(await new OrderShipped(order).content())
526
+
527
+ return html
528
+ })
529
+ ```
530
+
531
+ > [!NOTE]
532
+ > Laravel renders a mailable you return from a route. Oriel sends a returned mailable as JSON, like any other object,
533
+ > so render it first, as above.
534
+
535
+ ## Testing
536
+
537
+ `Mail.fake()` swaps the mailer for a fake that sends nothing and records each mail. Its assertions check what your
538
+ code sent or queued:
539
+
540
+ ```ts title="tests/Feature/OrderShipment.test.ts"
541
+ // @filename: tests/TestCase.ts
542
+ import { TestCase as BaseTestCase } from '@orieljs/framework/testing'
543
+ export class TestCase extends BaseTestCase {}
544
+ // ---cut---
545
+ import { Mail } from '@orieljs/framework'
546
+ import { test } from 'vitest'
547
+ import { OrderShipped } from '../../app/Mail/OrderShipped'
548
+ import { TestCase } from '../TestCase'
549
+
550
+ const t = TestCase.boot()
551
+
552
+ test('orders can be shipped', async () => {
553
+ const mail = Mail.fake()
554
+
555
+ await t.post('/orders/1/shipment')
556
+
557
+ mail.assertSent(OrderShipped, (mailable, record) => record.hasTo('taylor@example.com'))
558
+ mail.assertSent(OrderShipped, 1)
559
+ mail.assertNotQueued(OrderShipped)
560
+ })
561
+ ```
562
+
563
+ A mailable that would be queued counts as queued, not sent: check it with `assertQueued()`. The fake also has
564
+ `assertNotSent()`, `assertNothingSent()`, `assertSentCount()`, `assertNothingQueued()`, `assertQueuedCount()`,
565
+ `assertOutgoingCount()` and `assertNothingOutgoing()`, and `sent()` and `queued()` return the mailables. Each record
566
+ has `hasTo()`, `hasCc()`, `hasBcc()` and `hasSubject()`. Nothing is rendered, so the fake doesn't check your views.
567
+
568
+ ## Mail and local development
569
+
570
+ A new app's `MAIL_MAILER` is `log`, so every mail is written to `storage/logs/oriel.log`, with its recipients, its
571
+ subject and its text (or its HTML without the tags). With `MAIL_LOG_CHANNEL=mail`, the mails go to
572
+ `storage/logs/mail.log` instead, the `mail` channel of `config/logging.ts`. The links in your mails, such as a password
573
+ reset link, are in there.
574
+
575
+ To see your mails as a mail client shows them, run a local SMTP server that catches mail, such as Mailpit, and set
576
+ `MAIL_MAILER=smtp` with its host and port.
577
+
578
+ ## Custom transports
579
+
580
+ A transport is a class with a `send()` method, which receives the message, with its addresses, subject, HTML, text
581
+ and attachments, and returns the sent message's id:
582
+
583
+ ```ts title="app/Mail/Transports/HttpApiTransport.ts"
584
+ import { randomUUID } from 'node:crypto'
585
+ import type { MailTransport, OutgoingMessage, SentMessage } from '@orieljs/framework/contracts'
586
+
587
+ export class HttpApiTransport implements MailTransport {
588
+ constructor(private readonly key: string) {}
589
+
590
+ async send(message: OutgoingMessage): Promise<SentMessage> {
591
+ // Send message.from, message.to, message.subject, message.html, ... with the service's API.
592
+
593
+ return { messageId: randomUUID(), message }
594
+ }
595
+ }
596
+ ```
597
+
598
+ Register it with `Mail.extend()` in a service provider's `boot()`. The callback receives the mailer's entry of
599
+ `config/mail.ts`:
600
+
601
+ ```ts title="app/Providers/AppServiceProvider.ts"
602
+ // @filename: app/Mail/Transports/HttpApiTransport.ts
603
+ import type { MailTransport } from '@orieljs/framework/contracts'
604
+ export declare class HttpApiTransport implements MailTransport {
605
+ constructor(key: string)
606
+ send: MailTransport['send']
607
+ }
608
+ // ---cut---
609
+ import { Mail, ServiceProvider } from '@orieljs/framework'
610
+ import { HttpApiTransport } from '../Mail/Transports/HttpApiTransport'
611
+
612
+ export class AppServiceProvider extends ServiceProvider {
613
+ override boot(): void {
614
+ Mail.extend('http-api', (config) => new HttpApiTransport(String(config.key)))
615
+ }
616
+ }
617
+ ```
618
+
619
+ Then add a mailer that uses it to `config/mail.ts`: `api: { transport: 'http-api', key: env('MAIL_API_KEY') }`.