@orieljs/framework 0.1.0-alpha.6 → 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.
- package/NOTICE +4 -0
- package/README.md +10 -1
- package/TRADEMARKS.md +33 -0
- package/dist/{chunk-ZOEPKBVF.js → chunk-4MWG3B7G.js} +1347 -428
- package/dist/chunk-4MWG3B7G.js.map +1 -0
- package/dist/{chunk-POEP4B2M.js → chunk-7ZQZYDOC.js} +745 -105
- package/dist/chunk-7ZQZYDOC.js.map +1 -0
- package/dist/{chunk-I63ZFMX6.js → chunk-GVNLZZTN.js} +1 -1
- package/dist/chunk-GVNLZZTN.js.map +1 -0
- package/dist/{chunk-MFTZQMZM.js → chunk-VOYW3R5J.js} +4 -4
- package/dist/chunk-VOYW3R5J.js.map +1 -0
- package/dist/contracts.d.ts +515 -17
- package/dist/contracts.js +1 -1
- package/dist/index.d-WQpfdIbG.d.ts +630 -0
- package/dist/index.d.ts +964 -152
- package/dist/index.js +4279 -866
- package/dist/index.js.map +1 -1
- package/dist/{jsx-runtime-CBZQYYDS.d.ts → jsx-runtime-6um4Mwfb.d.ts} +96 -5
- package/dist/jsx-runtime.d.ts +2 -2
- package/dist/jsx-runtime.js +3 -3
- package/dist/{testing-BM481Hdz.d.ts → testing-CKwfWJGn.d.ts} +180 -15
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +3 -3
- package/docs/authentication.md +487 -0
- package/docs/authorization.md +435 -0
- package/docs/cache.md +647 -0
- package/docs/collections.md +2270 -0
- package/docs/configuration.md +334 -0
- package/docs/console.md +651 -0
- package/docs/container.md +430 -0
- package/docs/controllers.md +406 -0
- package/docs/csrf.md +126 -0
- package/docs/database-testing.md +144 -0
- package/docs/database.md +236 -0
- package/docs/deployment.md +276 -0
- package/docs/eloquent-collections.md +218 -0
- package/docs/eloquent-factories.md +255 -0
- package/docs/eloquent-mutators.md +254 -0
- package/docs/eloquent-observers.md +145 -0
- package/docs/eloquent-relationships.md +557 -0
- package/docs/eloquent.md +494 -0
- package/docs/encryption.md +104 -0
- package/docs/errors.md +318 -0
- package/docs/events.md +336 -0
- package/docs/facades.md +278 -0
- package/docs/hashing.md +118 -0
- package/docs/helpers.md +1281 -0
- package/docs/http-tests.md +386 -0
- package/docs/inertia.md +485 -0
- package/docs/installation.md +209 -0
- package/docs/lifecycle.md +234 -0
- package/docs/live-tests.md +226 -0
- package/docs/live.md +910 -0
- package/docs/logging.md +255 -0
- package/docs/mail.md +619 -0
- package/docs/middleware.md +384 -0
- package/docs/migrations.md +416 -0
- package/docs/mocking.md +264 -0
- package/docs/navigation.json +273 -0
- package/docs/notifications.md +225 -0
- package/docs/pagination.md +247 -0
- package/docs/passwords.md +207 -0
- package/docs/providers.md +187 -0
- package/docs/queries.md +600 -0
- package/docs/queues.md +330 -0
- package/docs/requests.md +533 -0
- package/docs/responses.md +444 -0
- package/docs/routing.md +738 -0
- package/docs/scheduling.md +277 -0
- package/docs/seeding.md +122 -0
- package/docs/session.md +409 -0
- package/docs/starter-kits.md +396 -0
- package/docs/storage.md +192 -0
- package/docs/structure.md +264 -0
- package/docs/testing.md +165 -0
- package/docs/time-travel.md +138 -0
- package/docs/validation.md +1792 -0
- package/docs/verification.md +152 -0
- package/docs/views.md +566 -0
- package/docs/vite.md +322 -0
- package/package.json +34 -32
- package/dist/chunk-I63ZFMX6.js.map +0 -1
- package/dist/chunk-MFTZQMZM.js.map +0 -1
- package/dist/chunk-POEP4B2M.js.map +0 -1
- package/dist/chunk-ZOEPKBVF.js.map +0 -1
- package/dist/index.d-BJyJx6U1.d.ts +0 -319
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') }`.
|