@dotpress/mailer 2.0.0-alpha.1 → 2.0.0-alpha.3

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/README.md CHANGED
@@ -1,491 +1,51 @@
1
1
  # @dotpress/mailer
2
2
 
3
- Typed, multilingual email templates for [Dotpress](https://dotpress.dev), written in [MJML](https://mjml.io) or HTML, with shared layouts, and interchangeable providers to send them. Works without a Dotpress app: in a script, a worker or a Lambda function.
3
+ Typed, multilingual email templates for [Dotpress](https://dotpress.dev), written in [MJML](https://mjml.io) or HTML, with shared layouts, data checked by a Zod schema, and interchangeable providers: SMTP, Amazon SES, Resend, SendGrid, Postmark, Brevo, Mailjet, Mailgun, Scaleway. Works without a Dotpress app: in a script, a worker or a Lambda function.
4
4
 
5
5
  > 🚧 Dotpress V2 is in alpha (`2.0.0-alpha`): the API may still change before 2.0.0.
6
6
 
7
7
  ```sh
8
- pnpm add @dotpress/mailer zod
9
- pnpm add mjml # for MJML templates
10
- pnpm add @dotpress/core # for mailerPlugin
8
+ npm install @dotpress/mailer zod
9
+ npm install mjml # for MJML templates
11
10
  ```
12
11
 
13
- ## Quick start
14
-
15
12
  ```ts
16
- import {
17
- createConsoleProvider,
18
- createMailer,
19
- defineLayout,
20
- defineTemplate,
21
- } from '@dotpress/mailer'
13
+ import { createMailer, defineTemplate } from '@dotpress/mailer'
14
+ import { createSmtpProvider } from '@dotpress/mailer/smtp'
22
15
  import { z } from 'zod'
23
16
 
24
- export const baseLayout = defineLayout({
25
- translations: {
26
- footer: { en: 'Sent by Acme.', fr: 'Envoyé par Acme.' },
27
- },
28
- mjml: ({ t, content, locale }) => `
29
- <mjml lang="${locale}">
30
- <mj-body background-color="#f6f6f6">
31
- <mj-section>
32
- <mj-column><mj-image src="https://cdn.acme.com/logo.png" width="40px" /></mj-column>
33
- </mj-section>
34
- ${content}
35
- <mj-section>
36
- <mj-column><mj-text font-size="12px">${t('footer')}</mj-text></mj-column>
37
- </mj-section>
38
- </mj-body>
39
- </mjml>`,
40
- })
41
-
42
17
  export const resetPassword = defineTemplate({
43
18
  name: 'resetPassword',
44
- layout: baseLayout,
45
19
  schema: z.object({ firstName: z.string(), resetUrl: z.url() }),
46
- subject: {
47
- en: 'Reset your password, {{firstName}}',
48
- fr: 'Réinitialisez votre mot de passe, {{firstName}}',
49
- },
20
+ subject: { en: 'Reset your password', fr: 'Réinitialisez votre mot de passe' },
50
21
  translations: {
51
- intro: {
52
- en: 'Click the button below to set a new password.',
53
- fr: 'Cliquez sur le bouton ci-dessous pour définir un nouveau mot de passe.',
54
- },
22
+ intro: { en: 'Hello {{firstName}}, set a new password:', fr: 'Bonjour {{firstName}}, choisissez un nouveau mot de passe :' },
55
23
  cta: { en: 'Reset my password', fr: 'Changer mon mot de passe' },
56
24
  },
57
25
  mjml: ({ t }) => `
58
- <mj-section background-color="#fff">
59
- <mj-column>
60
- <mj-text>Hello {{firstName}},<br /><br />${t('intro')}</mj-text>
61
- <mj-button href="{{{resetUrl}}}">${t('cta')}</mj-button>
62
- </mj-column>
63
- </mj-section>`,
26
+ <mjml><mj-body><mj-section><mj-column>
27
+ <mj-text>${t('intro')}</mj-text>
28
+ <mj-button href="{{{resetUrl}}}">${t('cta')}</mj-button>
29
+ </mj-column></mj-section></mj-body></mjml>`,
64
30
  })
65
31
 
66
32
  const mailer = createMailer({
67
- provider: createConsoleProvider(), // prints messages, for development
68
- from: 'Acme <noreply@acme.com>',
33
+ provider: createSmtpProvider(process.env.SMTP_URL!),
34
+ from: 'Example <noreply@example.com>',
69
35
  defaultLocale: 'en',
70
36
  })
71
37
 
72
38
  await mailer.send(resetPassword, {
73
39
  to: 'Ada Lovelace <ada@example.com>',
74
- locale: 'fr-CA', // fr-CA → fr → defaultLocale
75
- data: { firstName: 'Ada', resetUrl: 'https://app.acme.com/reset?token=…' },
76
- })
77
- ```
78
-
79
- ## How a template is rendered
80
-
81
- 1. **Translations**: the source function runs with `t()`, which returns the texts of the rendered locale. The layout function then receives the result as `content`, with its own `t()`.
82
- 2. **MJML** compiles the result to HTML. This step only depends on the template and the locale, so its result is cached.
83
- 3. **Mustache** inserts the data (`{{firstName}}`) in the HTML, the subject and the text versions.
84
-
85
- Since translations are inserted before Mustache, they can use the data too: `{ en: 'Hello {{firstName}}' }`. They are inserted as-is: they may contain HTML (`<br />`, `<strong>`).
86
-
87
- ## Templates
88
-
89
- ```ts
90
- defineTemplate({
91
- name: 'welcome', // used in errors and logs
92
- layout: baseLayout, // optional
93
- schema: z.object({ … }), // optional: without a schema, the template accepts no data
94
- subject: 'Welcome', // or { en: 'Welcome', fr: 'Bienvenue' }
95
- translations: { … }, // optional
96
- mjml: ({ t, locale }) => `…`, // or html, a string or a function
97
- text: ({ t, locale }) => `…`, // optional, a string or a function
98
- })
99
- ```
100
-
101
- Templates are checked when they are defined: unknown options, a missing source, texts that do not have the same locales, or a layout without translations in a locale of the template throw a `TypeError`. TypeScript reports most of these mistakes too.
102
-
103
- ### HTML templates
104
-
105
- Use `html` instead of `mjml` for HTML written by hand or produced by another tool. MJML is then never loaded. A layout and its templates must use the same format.
106
-
107
- ### Layouts
108
-
109
- A layout is a function that receives the template source as `content`. In MJML, place `content` inside `<mj-body>`: templates provide `<mj-section>` elements, so each section can have its own background.
110
-
111
- Layouts and templates have separate translations: the `t()` of a layout only reads the layout translations. A layout without translations can be used in any locale.
112
-
113
- Reusable blocks are plain functions:
114
-
115
- ```ts
116
- const button = (href: string, label: string) =>
117
- `<mj-button href="${href}" background-color="#1992d4">${label}</mj-button>`
118
-
119
- mjml: ({ t }) => `<mj-section><mj-column>${button('{{{url}}}', t('cta'))}</mj-column></mj-section>`
120
- ```
121
-
122
- `mj-include` is ignored: templates do not read files.
123
-
124
- ### Locales
125
-
126
- The locales of a template are those of its `subject` and `translations`; every text needs all of them. A template without translations takes the locales of its layout. A template and layout without translations ignore the locale.
127
-
128
- The rendered locale is the first available one among:
129
-
130
- 1. the requested locale (`fr-CA`), compared case-insensitively;
131
- 2. its base language (`fr`);
132
- 3. `defaultLocale`, then its base language.
133
-
134
- Otherwise, rendering fails. `t()` with an unknown key fails too.
135
-
136
- ### Data
137
-
138
- The data is validated by the schema (`safeParseAsync`, so async refinements work), and the parsed data is rendered. Invalid data throws a `MailDataError` with the list of issues.
139
-
140
- ### Escaping
141
-
142
- | Syntax | HTML version | Subject and text versions |
143
- | --- | --- | --- |
144
- | `{{value}}` | `& < > " '` escaped | inserted as-is |
145
- | `{{{value}}}` | inserted as-is | inserted as-is |
146
-
147
- Unlike the default escaping of Mustache, `/`, `=` and `` ` `` are not escaped: many email clients do not decode them in links. Use `{{{url}}}` for URLs built by your app, and `{{value}}` for everything else, in particular user input. Mustache sections work (`{{#items}}…{{/items}}`); partials do not.
148
-
149
- ### Subject
150
-
151
- The subject is rendered by Mustache without HTML escaping, then line breaks are replaced with spaces: a subject is a single line.
152
-
153
- ### Text version
154
-
155
- Without a `text` source, the text version is converted from the HTML version by [html-to-text](https://github.com/html-to-text/node-html-to-text): lines wrapped at 78 characters, links followed by their URL, images and hidden elements (like the MJML preview text) left out.
156
-
157
- ## Rendering without sending
158
-
159
- `mailer.render(template, { locale, data })` returns `{ template, locale, subject, html, text }`, with the default locale of the mailer. `renderMail(template, { locale, defaultLocale, data })` does the same without a mailer. Both are useful to preview templates, and to test them:
160
-
161
- ```ts
162
- for (const locale of resetPassword.locales) {
163
- const mail = await renderMail(resetPassword, { locale, data: sampleData })
164
- expect(mail.html).toContain('reset?token=')
165
- }
166
- ```
167
-
168
- ## Sending
169
-
170
- ```ts
171
- const mailer = createMailer({
172
- provider, // see Providers
173
- from: 'Acme <noreply@acme.com>', // default sender
174
- replyTo: 'support@acme.com', // optional default
175
- defaultLocale: 'en',
176
- logger, // optional: logs each sent message
177
- })
178
- ```
179
-
180
- ### `mailer.send(template, options)`
181
-
182
- Renders a template and sends it. Returns `{ messageId }`, the identifier given by the provider (if any).
183
-
184
- ```ts
185
- await mailer.send(invoiceMail, {
186
- to: ['ada@example.com', { name: 'Grace Hopper', address: 'grace@example.com' }],
187
- cc: 'accounting@example.com',
188
- bcc: ['archive@example.com'],
189
- replyTo: 'billing@acme.com', // overrides the default
190
- from: 'Acme Billing <billing@acme.com>', // overrides the default
191
- subject: 'Your invoice', // overrides the template subject
192
- locale: user.locale,
193
- data: { invoiceNumber: 'F-2026-001' },
194
- attachments: [
195
- { filename: 'invoice.pdf', content: pdfBytes, contentType: 'application/pdf' },
196
- { filename: 'logo.png', content: logoBytes, contentId: 'logo' }, // <img src="cid:logo">
197
- ],
198
- headers: { 'List-Unsubscribe': '<https://acme.com/unsubscribe?u=42>' },
199
- tags: ['invoice'], // see Tags and metadata
200
- metadata: { invoiceId: 'F-2026-001' },
201
- })
202
- ```
203
-
204
- - **Addresses**: `'ada@example.com'`, `'Ada Lovelace <ada@example.com>'` or `{ name, address }`, alone or in a list. `to` needs at least one address.
205
- - **Attachments**: `content` is a `Uint8Array` (a Node.js `Buffer` works) or a string. `contentId` makes the attachment inline.
206
- - **Headers**: additional headers only. `From`, `To`, `Subject`, `Content-Type` and the other headers set by the mailer are rejected.
207
-
208
- Recipients, attachments, headers, tags and metadata are checked before rendering: invalid values (an address without `@`, a line break in a name or a header) throw a `MailMessageError`.
209
-
210
- ### `mailer.sendMessage(options)`
211
-
212
- Sends a message rendered elsewhere: the same options, with `subject`, and `html`, `text` or both. Without `text`, the text version is converted from `html`.
213
-
214
- ```ts
215
- await mailer.sendMessage({ to, subject: 'Export ready', html: '<p>…</p>' })
216
- ```
217
-
218
- ### Tags and metadata
219
-
220
- Most providers can group messages in their statistics (**tags**), and return key/value pairs in their webhooks to link an event (delivery, bounce, click…) to your app (**metadata**).
221
-
222
- ```ts
223
- await mailer.send(invoiceMail, {
224
- to,
225
- data,
226
- tags: ['invoice', 'billing'],
227
- metadata: { invoiceId: 'F-2026-001', userId: '42' },
40
+ locale: 'fr-CA', // fr-CA, then fr, then the default locale
41
+ data: { firstName: 'Ada', resetUrl: 'https://app.example.com/reset?token=…' },
228
42
  })
229
43
  ```
230
44
 
231
- - **Tags** and **metadata keys** have 1 to 256 letters, digits, `_` or `-`: the strictest rule among providers, so they are valid everywhere. Duplicate tags are removed.
232
- - **Metadata values** are strings on one line.
233
-
234
- Each provider translates them to its own API. A provider without tags or metadata ignores them: switching providers never breaks a call, only the grouping is lost.
235
-
236
- | Provider | `tags` | `metadata` |
237
- | --- | --- | --- |
238
- | SendGrid | `categories` | `custom_args` |
239
- | Postmark | `Tag` (a single tag) | `Metadata` |
240
- | Brevo | `tags` | ignored |
241
- | Mailjet | ignored | `EventPayload` (JSON) |
242
- | Mailgun | `o:tag` | `v:` variables |
243
- | Scaleway | ignored | ignored |
244
- | Amazon SES | `EmailTags`, with the value `true` | `EmailTags` |
245
- | Resend | `tags`, with the value `true` | `tags` |
246
- | SMTP, nodemailer | ignored | ignored |
247
- | console | printed | printed |
248
- | memory | kept in the message | kept in the message |
249
-
250
- The limits of each provider (number of tags, characters of the values…) are listed in [Limits of the providers](#limits-of-the-providers).
251
-
252
- ## Providers
253
-
254
- | Provider | Import | Use |
255
- | --- | --- | --- |
256
- | `createMemoryProvider()` | `@dotpress/mailer` | tests: keeps messages in `outbox.sent`, checked by `expectMailSent()` of `@dotpress/testing` |
257
- | `createConsoleProvider()` | `@dotpress/mailer` | development: prints the sender, recipients, subject, attachments, tags and metadata |
258
- | `createSmtpProvider()` | `@dotpress/mailer/smtp` | any SMTP server, through nodemailer |
259
- | `createNodemailerProvider()` | `@dotpress/mailer/smtp` | any nodemailer transporter (sendmail, stream…) |
260
- | `createSesProvider()` | `@dotpress/mailer/aws-ses` | Amazon SES (API v2), through the AWS SDK |
261
- | `createResendProvider()` | `@dotpress/mailer/resend` | Resend, through its SDK |
262
- | `createSendgridProvider()` | `@dotpress/mailer/sendgrid` | Twilio SendGrid (Mail Send API v3), through `fetch` |
263
- | `createPostmarkProvider()` | `@dotpress/mailer/postmark` | Postmark (Email API), through `fetch` |
264
- | `createBrevoProvider()` | `@dotpress/mailer/brevo` | Brevo (transactional email API v3), through `fetch` |
265
- | `createMailjetProvider()` | `@dotpress/mailer/mailjet` | Mailjet (Send API v3.1), through `fetch` |
266
- | `createMailgunProvider()` | `@dotpress/mailer/mailgun` | Mailgun (messages API), through `fetch` |
267
- | `createScalewayProvider()` | `@dotpress/mailer/scaleway` | Scaleway Transactional Email, through `fetch` |
268
-
269
- Every provider sends the sender and reply-to addresses, multiple `to`, `cc` and `bcc` recipients, the HTML and text versions, attachments (inline ones included) and additional headers, within the limits of its API: a message that the API cannot accept throws a `MailMessageError` before any request (see [Limits of the providers](#limits-of-the-providers)).
270
-
271
- The providers based on an HTTP API (SendGrid, Postmark, Brevo, Mailjet, Mailgun, Scaleway) have no dependency, and take two common options: `baseUrl` (region, proxy, tests) and `fetch` (replaces the global `fetch`). Their errors are thrown as `MailSendError`, with the HTTP `status` and the message of the API.
272
-
273
- The console provider prints the text version only with `{ body: true }`: messages often contain secrets (reset links, login codes). Never use it as a fallback when the configuration of the real provider is missing: messages would be lost silently.
274
-
275
- ```ts
276
- const outbox = createMemoryProvider()
277
- const mailer = createMailer({ provider: outbox, from: 'noreply@example.com' })
278
-
279
- await signUp('ada@example.com')
280
- expect(outbox.last()).toMatchObject({
281
- to: [{ address: 'ada@example.com' }],
282
- template: 'welcome',
283
- })
284
- outbox.clear()
285
- ```
286
-
287
- ### SMTP
288
-
289
- ```sh
290
- pnpm add nodemailer
291
- ```
292
-
293
- ```ts
294
- import { createSmtpProvider } from '@dotpress/mailer/smtp'
295
-
296
- const provider = createSmtpProvider({
297
- host: 'smtp.example.com',
298
- port: 465,
299
- secure: true,
300
- auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASSWORD! },
301
- pool: true, // optional: reuses connections
302
- })
303
- // or createSmtpProvider('smtps://user:password@smtp.example.com')
304
-
305
- // On shutdown, with pool: true:
306
- provider.close()
307
- ```
308
-
309
- The options are those of [nodemailer](https://nodemailer.com/smtp/) (nodemailer 10). Most email services also have an SMTP interface: Amazon SES, Postmark, Mailgun, Brevo, Mailjet… SMTP errors (`cause.responseCode`, like `550`) are thrown as `MailSendError`.
310
-
311
- `createNodemailerProvider(transporter, name?)` wraps any nodemailer transporter, for instance `nodemailer.createTransport({ sendmail: true })`.
312
-
313
- The SMTP provider runs on Node.js only.
314
-
315
- ### Amazon SES
316
-
317
- ```sh
318
- pnpm add @aws-sdk/client-sesv2
319
- ```
320
-
321
- ```ts
322
- import { createSesProvider } from '@dotpress/mailer/aws-ses'
323
-
324
- const provider = createSesProvider({
325
- clientConfig: { region: 'eu-west-3' }, // credentials from the environment
326
- configurationSetName: 'transactional', // optional
327
- })
328
- // or createSesProvider({ client: existingSesV2Client })
329
- ```
330
-
331
- Messages are sent with the `SendEmail` operation of the SES API v2 (simple content: subject, HTML and text versions, headers and attachments; `@aws-sdk/client-sesv2` 3.782 or later). Display names with non-ASCII characters are encoded as SES requires. SES errors are thrown as `MailSendError`, with the HTTP `status` and the error name (`MessageRejected: Email address is not verified.`).
332
-
333
- On AWS Lambda, the Node.js runtimes include the AWS SDK: it does not need to be bundled, as long as the version of the runtime is 3.782 or later.
334
-
335
- ### Resend
336
-
337
- ```sh
338
- pnpm add resend
339
- ```
340
-
341
- ```ts
342
- import { createResendProvider } from '@dotpress/mailer/resend'
343
-
344
- const provider = createResendProvider({ apiKey: process.env.RESEND_API_KEY })
345
- // or createResendProvider({ client: existingResendClient })
346
- ```
347
-
348
- Without `apiKey`, the SDK reads the `RESEND_API_KEY` environment variable. Resend errors are thrown as `MailSendError`, with the HTTP `status` and the error name (`validation_error: …`).
349
-
350
- ### SendGrid
351
-
352
- ```ts
353
- import { createSendgridProvider } from '@dotpress/mailer/sendgrid'
354
-
355
- const provider = createSendgridProvider({
356
- apiKey: process.env.SENDGRID_API_KEY!,
357
- // baseUrl: 'https://api.eu.sendgrid.com', // EU regional subusers
358
- })
359
- ```
360
-
361
- The provider calls the [Mail Send API](https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send) (v3) with `fetch`: no SDK to install. The message ID is read from the `X-Message-Id` response header, when present. API errors are thrown as `MailSendError`, with the HTTP `status` and the messages of the response (`400 Invalid from email address`). A `fetch` option replaces the global `fetch` (proxy, tests).
362
-
363
- ### Postmark
364
-
365
- ```ts
366
- import { createPostmarkProvider } from '@dotpress/mailer/postmark'
367
-
368
- const provider = createPostmarkProvider({
369
- serverToken: process.env.POSTMARK_SERVER_TOKEN!, // POSTMARK_API_TEST: test mode
370
- messageStream: 'outbound', // optional, the transactional stream by default
371
- })
372
- ```
373
-
374
- The provider calls the [Email API](https://postmarkapp.com/developer/api/email-api). The message ID is the `MessageID` of the response. API errors include the Postmark `ErrorCode` (`422 Invalid email request (ErrorCode 300)`).
375
-
376
- ### Brevo
377
-
378
- ```ts
379
- import { createBrevoProvider } from '@dotpress/mailer/brevo'
380
-
381
- const provider = createBrevoProvider({ apiKey: process.env.BREVO_API_KEY! })
382
- ```
383
-
384
- The provider calls the [transactional email API](https://developers.brevo.com/reference/sendtransacemail). The message ID is the `messageId` of the response. API errors include the Brevo `code` (`400 invalid_parameter: …`).
385
-
386
- ### Mailjet
387
-
388
- ```ts
389
- import { createMailjetProvider } from '@dotpress/mailer/mailjet'
390
-
391
- const provider = createMailjetProvider({
392
- apiKey: process.env.MAILJET_API_KEY!,
393
- apiSecret: process.env.MAILJET_API_SECRET!,
394
- })
395
- ```
396
-
397
- The provider calls the [Send API v3.1](https://dev.mailjet.com/email/reference/send-emails/). Mailjet returns an identifier per recipient: the message ID is the `MessageUUID` of the first `to` recipient (the numeric `MessageID` when the UUID is missing). Metadata is sent as `EventPayload` (JSON), returned by the event webhooks. A message rejected in a successful response is reported as an error too.
398
-
399
- ### Mailgun
400
-
401
- ```ts
402
- import { createMailgunProvider } from '@dotpress/mailer/mailgun'
403
-
404
- const provider = createMailgunProvider({
405
- apiKey: process.env.MAILGUN_API_KEY!, // or a sending key of the domain
406
- domain: 'mg.example.com',
407
- // baseUrl: 'https://api.eu.mailgun.net', // domains of the EU region
408
- })
409
- ```
410
-
411
- The provider calls the [messages API](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/messages/post-v3--domain-name--messages) with `multipart/form-data` (`FormData`). Reply-to addresses and additional headers are sent as `h:` options, tags as `o:tag` and metadata as `v:` variables. Mailgun references inline images by file name: an inline attachment is sent with its `contentId` as file name, so `<img src="cid:logo">` works as with the other providers. The message ID is the `id` of the response.
412
-
413
- ### Scaleway
414
-
415
- ```ts
416
- import { createScalewayProvider } from '@dotpress/mailer/scaleway'
417
-
418
- const provider = createScalewayProvider({
419
- secretKey: process.env.SCW_SECRET_KEY!,
420
- projectId: process.env.SCW_DEFAULT_PROJECT_ID!,
421
- region: 'fr-par', // default
422
- })
423
- ```
424
-
425
- The provider calls the Transactional Email API (`v1alpha1`), with the request format of the official Scaleway SDK. The API has no reply-to field: reply-to addresses are sent as a `Reply-To` additional header. Scaleway returns an email per recipient: the message ID is the `id` of the first one, usable with the API to get the status of the email. Tags and metadata are ignored.
426
-
427
- ### Limits of the providers
428
-
429
- Checked before calling the API; they throw a `MailMessageError`.
430
-
431
- | Provider | Limits |
432
- | --- | --- |
433
- | SendGrid | 10 tags of 255 characters at most; metadata of 10,000 bytes at most (JSON); requests of 30 MB at most |
434
- | Amazon SES, Resend | metadata values of 1 to 256 letters, digits, `_` or `-` (use identifiers, not email addresses or dates); a name cannot be both a tag and a metadata key, since both end up in the same list |
435
- | Postmark | 50 recipients (`to`, `cc` and `bcc`); addresses of 254 characters and a sender of 255 characters at most; subjects of 2,000 characters at most; a single tag; no executable attachments (`.exe`, `.bat`, `.msi`…); requests of 10 MB at most |
436
- | Brevo | a single reply-to address; no inline attachments; names of 70 characters at most; attachment extensions accepted by Brevo only (`pdf`, `png`, `docx`, `zip`…) |
437
- | Mailjet | a single reply-to address; requests of 15 MB at most |
438
- | Mailgun | 16 KB at most (16,384 bytes) for the reply-to addresses, additional headers, tags and metadata together |
439
- | Scaleway | no inline attachments; subjects of 998 characters at most; no empty header values |
440
-
441
- Request sizes include the attachments, encoded in base64 (a third larger than the files).
442
-
443
- ### Custom providers
444
-
445
- A provider has a `name` and a `send(message)` method, which returns `{ messageId }` or nothing. The message is normalized and frozen: `from`, `to`, `cc`, `bcc`, `replyTo` (`{ name?, address }`), `subject`, `html` (undefined for a text-only message), `text`, `attachments`, `headers`, `tags`, `metadata`, plus `template` and `locale` for logs. `formatAddress()` formats an address for a header (`"Ada" <ada@example.com>`).
446
-
447
- Throw a `MailSendError` with the HTTP `status` of the API, if any, and a `MailMessageError` for a message the provider cannot send (a limit of its API). Other errors are wrapped in a `MailSendError`.
448
-
449
- ## Dotpress plugin
450
-
451
- ```ts
452
- import { mailerPlugin } from '@dotpress/mailer/plugin'
453
-
454
- await createApp({ adapter, plugins: [mailerPlugin(mailer)] })
455
-
456
- defineRoute({
457
- method: 'post',
458
- path: '/invitations',
459
- handler: async ({ mailer, body }) => {
460
- await mailer.send(invitation, { to: body.email, data: { team: body.team } })
461
- return { sent: true }
462
- },
463
- })
464
- ```
465
-
466
- `ctx.mailer` logs sent messages with the request logger, so logs carry the request ID. Using `ctx.mailer` without the plugin throws an explicit error. The plugin is in its own entry point: the main one does not depend on `@dotpress/core`.
467
-
468
- Sending is not retried and not queued: for that, send from a job or a queue consumer.
469
-
470
- ## Logs
471
-
472
- With a `logger` (a Dotpress or Pino logger, or any object with an `info(data, message)` method), each sent message is logged at the `info` level as `Mail sent`, with the provider, the template, the message ID and the number of recipients. Addresses are not logged. Failures are thrown, not logged.
473
-
474
- ## Performance
475
-
476
- MJML is loaded on the first render of an MJML template (about 100 to 300 ms), and html-to-text on the first text conversion. MJML compilation is cached by template and locale: later renders only run Mustache, in well under a millisecond.
477
-
478
- ## Errors
479
-
480
- | Error | When |
481
- | --- | --- |
482
- | `TypeError` | invalid options of `defineTemplate()`, `defineLayout()`, `createMailer()`, `mailer.send()`… |
483
- | `MailDataError` | data rejected by the schema (`template`, `issues`) |
484
- | `MailRenderError` | MJML missing or invalid, unknown translation key, no available locale, invalid Mustache, failing source function (`template`, `cause`) |
485
- | `MailMessageError` | invalid address, attachment, header, tag or metadata, or a limit of the provider |
486
- | `MailSendError` | the provider failed (`provider`, `status`, `cause`) |
45
+ ## Documentation
487
46
 
488
- The other error classes extend `MailerError`.
47
+ - [Emails](https://dotpress.dev/services/mailer): layouts and templates, rendering and escaping, locales, sending, providers, the plugin, testing
48
+ - [Mailer reference](https://dotpress.dev/reference/mailer): every option, the settings and limits of each provider, errors
489
49
 
490
50
  ## License
491
51
 
package/dist/aws-ses.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  import {
5
5
  base64,
6
6
  utf8
7
- } from "./chunk-2ZABFOZQ.js";
7
+ } from "./chunk-4FKEUJSR.js";
8
8
  import {
9
9
  MailSendError,
10
10
  checkKeys,
package/dist/brevo.js CHANGED
@@ -5,10 +5,10 @@ import {
5
5
  readJson,
6
6
  responseError,
7
7
  stringOf
8
- } from "./chunk-FRMKUAF4.js";
8
+ } from "./chunk-3EMC5BO6.js";
9
9
  import {
10
10
  base64
11
- } from "./chunk-2ZABFOZQ.js";
11
+ } from "./chunk-4FKEUJSR.js";
12
12
  import {
13
13
  MailMessageError
14
14
  } from "./chunk-QYVN5N3B.js";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  utf8
3
- } from "./chunk-2ZABFOZQ.js";
3
+ } from "./chunk-4FKEUJSR.js";
4
4
  import {
5
5
  MailMessageError,
6
6
  MailSendError,
@@ -85,4 +85,4 @@ export {
85
85
  checkSingleReplyTo,
86
86
  checkNoInline
87
87
  };
88
- //# sourceMappingURL=chunk-FRMKUAF4.js.map
88
+ //# sourceMappingURL=chunk-3EMC5BO6.js.map
@@ -0,0 +1,19 @@
1
+ // src/encoding.ts
2
+ var encoder = new TextEncoder();
3
+ var utf8 = (text) => encoder.encode(text);
4
+ var CHUNK = 32768;
5
+ var base64 = (content) => {
6
+ const bytes = typeof content === "string" ? utf8(content) : content;
7
+ if (typeof bytes.toBase64 === "function") return bytes.toBase64();
8
+ let binary = "";
9
+ for (let start = 0; start < bytes.length; start += CHUNK) {
10
+ binary += String.fromCharCode(...bytes.subarray(start, start + CHUNK));
11
+ }
12
+ return btoa(binary);
13
+ };
14
+
15
+ export {
16
+ utf8,
17
+ base64
18
+ };
19
+ //# sourceMappingURL=chunk-4FKEUJSR.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/encoding.ts"],"sourcesContent":["const encoder = new TextEncoder()\n\n/** UTF-8 bytes of a string. */\nexport const utf8 = (text: string) => encoder.encode(text)\n\n/** Bytes turned into characters at once: below the argument limits. */\nconst CHUNK = 0x8000\n\n/** Uint8Array of the runtimes that encode base64 natively. */\ntype WithBase64 = Uint8Array & { toBase64?: () => string }\n\n/**\n * Base64 of bytes, or of the UTF-8 bytes of a string (Web APIs only):\n * `Uint8Array#toBase64` when the runtime has it, `btoa` otherwise.\n */\nexport const base64 = (content: Uint8Array | string) => {\n const bytes: WithBase64 =\n typeof content === 'string' ? utf8(content) : content\n if (typeof bytes.toBase64 === 'function') return bytes.toBase64()\n let binary = ''\n for (let start = 0; start < bytes.length; start += CHUNK) {\n binary += String.fromCharCode(...bytes.subarray(start, start + CHUNK))\n }\n return btoa(binary)\n}\n"],"mappings":";AAAA,IAAM,UAAU,IAAI,YAAY;AAGzB,IAAM,OAAO,CAAC,SAAiB,QAAQ,OAAO,IAAI;AAGzD,IAAM,QAAQ;AASP,IAAM,SAAS,CAAC,YAAiC;AACtD,QAAM,QACJ,OAAO,YAAY,WAAW,KAAK,OAAO,IAAI;AAChD,MAAI,OAAO,MAAM,aAAa,WAAY,QAAO,MAAM,SAAS;AAChE,MAAI,SAAS;AACb,WAAS,QAAQ,GAAG,QAAQ,MAAM,QAAQ,SAAS,OAAO;AACxD,cAAU,OAAO,aAAa,GAAG,MAAM,SAAS,OAAO,QAAQ,KAAK,CAAC;AAAA,EACvE;AACA,SAAO,KAAK,MAAM;AACpB;","names":[]}
package/dist/mailgun.js CHANGED
@@ -3,11 +3,11 @@ import {
3
3
  readJson,
4
4
  responseError,
5
5
  stringOf
6
- } from "./chunk-FRMKUAF4.js";
6
+ } from "./chunk-3EMC5BO6.js";
7
7
  import {
8
8
  base64,
9
9
  utf8
10
- } from "./chunk-2ZABFOZQ.js";
10
+ } from "./chunk-4FKEUJSR.js";
11
11
  import {
12
12
  MailMessageError,
13
13
  formatAddress
package/dist/mailjet.js CHANGED
@@ -5,10 +5,10 @@ import {
5
5
  readJson,
6
6
  responseError,
7
7
  stringOf
8
- } from "./chunk-FRMKUAF4.js";
8
+ } from "./chunk-3EMC5BO6.js";
9
9
  import {
10
10
  base64
11
- } from "./chunk-2ZABFOZQ.js";
11
+ } from "./chunk-4FKEUJSR.js";
12
12
  import {
13
13
  MailSendError
14
14
  } from "./chunk-QYVN5N3B.js";
package/dist/postmark.js CHANGED
@@ -4,10 +4,10 @@ import {
4
4
  readJson,
5
5
  responseError,
6
6
  stringOf
7
- } from "./chunk-FRMKUAF4.js";
7
+ } from "./chunk-3EMC5BO6.js";
8
8
  import {
9
9
  base64
10
- } from "./chunk-2ZABFOZQ.js";
10
+ } from "./chunk-4FKEUJSR.js";
11
11
  import {
12
12
  MailMessageError,
13
13
  formatAddress
package/dist/resend.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-GOAXQ2GE.js";
4
4
  import {
5
5
  base64
6
- } from "./chunk-2ZABFOZQ.js";
6
+ } from "./chunk-4FKEUJSR.js";
7
7
  import {
8
8
  MailSendError,
9
9
  checkKeys,
package/dist/scaleway.js CHANGED
@@ -4,10 +4,10 @@ import {
4
4
  readJson,
5
5
  responseError,
6
6
  stringOf
7
- } from "./chunk-FRMKUAF4.js";
7
+ } from "./chunk-3EMC5BO6.js";
8
8
  import {
9
9
  base64
10
- } from "./chunk-2ZABFOZQ.js";
10
+ } from "./chunk-4FKEUJSR.js";
11
11
  import {
12
12
  MailMessageError,
13
13
  formatAddress
package/dist/sendgrid.js CHANGED
@@ -4,11 +4,11 @@ import {
4
4
  readJson,
5
5
  responseError,
6
6
  stringOf
7
- } from "./chunk-FRMKUAF4.js";
7
+ } from "./chunk-3EMC5BO6.js";
8
8
  import {
9
9
  base64,
10
10
  utf8
11
- } from "./chunk-2ZABFOZQ.js";
11
+ } from "./chunk-4FKEUJSR.js";
12
12
  import {
13
13
  MailMessageError
14
14
  } from "./chunk-QYVN5N3B.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotpress/mailer",
3
- "version": "2.0.0-alpha.1",
3
+ "version": "2.0.0-alpha.3",
4
4
  "description": "Typed, multilingual email templates (MJML or HTML) and interchangeable providers",
5
5
  "author": "Renaud Guyon",
6
6
  "license": "MIT",
@@ -71,7 +71,7 @@
71
71
  },
72
72
  "peerDependencies": {
73
73
  "@aws-sdk/client-sesv2": "^3.782.0",
74
- "@dotpress/core": "^2.0.0-alpha.1",
74
+ "@dotpress/core": "^2.0.0-alpha.3",
75
75
  "mjml": "^5.0.0",
76
76
  "nodemailer": "^10.0.0",
77
77
  "resend": "^6.0.0",
@@ -100,8 +100,8 @@
100
100
  },
101
101
  "devDependencies": {
102
102
  "@aws-sdk/client-sesv2": "^3.1164.0",
103
- "@dotpress/core": "2.0.0-alpha.1",
104
- "@dotpress/testing": "2.0.0-alpha.1",
103
+ "@dotpress/core": "2.0.0-alpha.3",
104
+ "@dotpress/testing": "2.0.0-alpha.3",
105
105
  "@types/html-to-text": "^9.0.4",
106
106
  "@types/mjml": "^5.4.2",
107
107
  "@types/mustache": "^4.2.6",
@@ -1,15 +0,0 @@
1
- // src/encoding.ts
2
- var encoder = new TextEncoder();
3
- var utf8 = (text) => encoder.encode(text);
4
- var base64 = (content) => {
5
- const bytes = typeof content === "string" ? utf8(content) : content;
6
- let binary = "";
7
- for (const byte of bytes) binary += String.fromCharCode(byte);
8
- return btoa(binary);
9
- };
10
-
11
- export {
12
- utf8,
13
- base64
14
- };
15
- //# sourceMappingURL=chunk-2ZABFOZQ.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/encoding.ts"],"sourcesContent":["const encoder = new TextEncoder()\n\n/** UTF-8 bytes of a string. */\nexport const utf8 = (text: string) => encoder.encode(text)\n\n/** Base64 of bytes, or of the UTF-8 bytes of a string (Web APIs only). */\nexport const base64 = (content: Uint8Array | string) => {\n const bytes = typeof content === 'string' ? utf8(content) : content\n let binary = ''\n for (const byte of bytes) binary += String.fromCharCode(byte)\n return btoa(binary)\n}\n"],"mappings":";AAAA,IAAM,UAAU,IAAI,YAAY;AAGzB,IAAM,OAAO,CAAC,SAAiB,QAAQ,OAAO,IAAI;AAGlD,IAAM,SAAS,CAAC,YAAiC;AACtD,QAAM,QAAQ,OAAO,YAAY,WAAW,KAAK,OAAO,IAAI;AAC5D,MAAI,SAAS;AACb,aAAW,QAAQ,MAAO,WAAU,OAAO,aAAa,IAAI;AAC5D,SAAO,KAAK,MAAM;AACpB;","names":[]}