@dotpress/mailer 0.0.0-stage → 2.0.0-alpha.0

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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +491 -2
  3. package/dist/aws-ses.d.ts +30 -0
  4. package/dist/aws-ses.js +108 -0
  5. package/dist/aws-ses.js.map +1 -0
  6. package/dist/brevo.d.ts +23 -0
  7. package/dist/brevo.js +115 -0
  8. package/dist/brevo.js.map +1 -0
  9. package/dist/chunk-2ZABFOZQ.js +15 -0
  10. package/dist/chunk-2ZABFOZQ.js.map +1 -0
  11. package/dist/chunk-FRMKUAF4.js +88 -0
  12. package/dist/chunk-FRMKUAF4.js.map +1 -0
  13. package/dist/chunk-GOAXQ2GE.js +28 -0
  14. package/dist/chunk-GOAXQ2GE.js.map +1 -0
  15. package/dist/chunk-QYVN5N3B.js +221 -0
  16. package/dist/chunk-QYVN5N3B.js.map +1 -0
  17. package/dist/http-B35l9W1d.d.ts +9 -0
  18. package/dist/index.d.ts +97 -0
  19. package/dist/index.js +649 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/mailer-CmQCJ0qc.d.ts +339 -0
  22. package/dist/mailgun.d.ts +31 -0
  23. package/dist/mailgun.js +100 -0
  24. package/dist/mailgun.js.map +1 -0
  25. package/dist/mailjet.d.ts +28 -0
  26. package/dist/mailjet.js +104 -0
  27. package/dist/mailjet.js.map +1 -0
  28. package/dist/plugin.d.ts +33 -0
  29. package/dist/plugin.js +21 -0
  30. package/dist/plugin.js.map +1 -0
  31. package/dist/postmark.d.ts +27 -0
  32. package/dist/postmark.js +149 -0
  33. package/dist/postmark.js.map +1 -0
  34. package/dist/resend.d.ts +31 -0
  35. package/dist/resend.js +92 -0
  36. package/dist/resend.js.map +1 -0
  37. package/dist/scaleway.d.ts +31 -0
  38. package/dist/scaleway.js +113 -0
  39. package/dist/scaleway.js.map +1 -0
  40. package/dist/sendgrid.d.ts +26 -0
  41. package/dist/sendgrid.js +121 -0
  42. package/dist/sendgrid.js.map +1 -0
  43. package/dist/smtp.d.ts +45 -0
  44. package/dist/smtp.js +61 -0
  45. package/dist/smtp.js.map +1 -0
  46. package/package.json +139 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Renaud Guyon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,492 @@
1
- # Temporary Holding Version
1
+ # @dotpress/mailer
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
4
+
5
+ > 🚧 Dotpress V2 is under active development and not published yet.
6
+
7
+ ```sh
8
+ pnpm add @dotpress/mailer zod
9
+ pnpm add mjml # for MJML templates
10
+ pnpm add @dotpress/core # for mailerPlugin
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```ts
16
+ import {
17
+ createConsoleProvider,
18
+ createMailer,
19
+ defineLayout,
20
+ defineTemplate,
21
+ } from '@dotpress/mailer'
22
+ import { z } from 'zod'
23
+
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
+ export const resetPassword = defineTemplate({
43
+ name: 'resetPassword',
44
+ layout: baseLayout,
45
+ 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
+ },
50
+ 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
+ },
55
+ cta: { en: 'Reset my password', fr: 'Changer mon mot de passe' },
56
+ },
57
+ 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>`,
64
+ })
65
+
66
+ const mailer = createMailer({
67
+ provider: createConsoleProvider(), // prints messages, for development
68
+ from: 'Acme <noreply@acme.com>',
69
+ defaultLocale: 'en',
70
+ })
71
+
72
+ await mailer.send(resetPassword, {
73
+ 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' },
228
+ })
229
+ ```
230
+
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`) |
487
+
488
+ The other error classes extend `MailerError`.
489
+
490
+ ## License
491
+
492
+ MIT
@@ -0,0 +1,30 @@
1
+ import { SendEmailCommand, SendEmailCommandOutput, SESv2ClientConfig } from '@aws-sdk/client-sesv2';
2
+ import { MailProvider } from './index.js';
3
+ import 'zod';
4
+
5
+ /** An SES client, or any object with the same `send()` method. */
6
+ interface SesClient {
7
+ send(command: SendEmailCommand): Promise<SendEmailCommandOutput>;
8
+ }
9
+ /** Options of `createSesProvider()`. */
10
+ interface SesProviderOptions {
11
+ /** Existing client. Defaults to a client created with `clientConfig`. */
12
+ client?: SesClient | undefined;
13
+ /** Configuration of the created client: `region`, `credentials`… */
14
+ clientConfig?: SESv2ClientConfig | undefined;
15
+ /** SES configuration set (events, dedicated IPs). */
16
+ configurationSetName?: string | undefined;
17
+ }
18
+ /**
19
+ * Creates a provider that sends messages with Amazon SES (API v2), through
20
+ * the AWS SDK (`@aws-sdk/client-sesv2`, peer dependency). Credentials and
21
+ * region come from the environment unless `clientConfig` sets them.
22
+ *
23
+ * ```ts
24
+ * const provider = createSesProvider({ clientConfig: { region: 'eu-west-3' } })
25
+ * ```
26
+ */
27
+ declare const createSesProvider: (options?: SesProviderOptions) => MailProvider;
28
+
29
+ export { createSesProvider };
30
+ export type { SesClient, SesProviderOptions };
@@ -0,0 +1,108 @@
1
+ import {
2
+ keyValueTags
3
+ } from "./chunk-GOAXQ2GE.js";
4
+ import {
5
+ base64,
6
+ utf8
7
+ } from "./chunk-2ZABFOZQ.js";
8
+ import {
9
+ MailSendError,
10
+ checkKeys,
11
+ formatAddress
12
+ } from "./chunk-QYVN5N3B.js";
13
+
14
+ // src/providers/aws-ses.ts
15
+ import {
16
+ SendEmailCommand,
17
+ SESv2Client
18
+ } from "@aws-sdk/client-sesv2";
19
+ var OPTIONS = /* @__PURE__ */ new Set(["client", "clientConfig", "configurationSetName"]);
20
+ var UTF8 = "UTF-8";
21
+ var toSesAddress = (address) => address.name !== void 0 && /[^\x20-\x7e]/.test(address.name) ? `=?UTF-8?B?${base64(address.name)}?= <${address.address}>` : formatAddress(address);
22
+ var emailTags = (message) => {
23
+ const tags = keyValueTags(message, "SES");
24
+ return tags.length > 0 ? {
25
+ EmailTags: tags.map(({ name, value }) => ({
26
+ Name: name,
27
+ Value: value
28
+ }))
29
+ } : {};
30
+ };
31
+ var toSendEmailInput = (message, configurationSetName) => ({
32
+ FromEmailAddress: toSesAddress(message.from),
33
+ Destination: {
34
+ ToAddresses: message.to.map(toSesAddress),
35
+ CcAddresses: message.cc.map(toSesAddress),
36
+ BccAddresses: message.bcc.map(toSesAddress)
37
+ },
38
+ ReplyToAddresses: message.replyTo.map(toSesAddress),
39
+ Content: {
40
+ Simple: {
41
+ Subject: { Data: message.subject, Charset: UTF8 },
42
+ Body: {
43
+ Text: { Data: message.text, Charset: UTF8 },
44
+ ...message.html === void 0 ? {} : { Html: { Data: message.html, Charset: UTF8 } }
45
+ },
46
+ Headers: Object.entries(message.headers).map(([Name, Value]) => ({
47
+ Name,
48
+ Value
49
+ })),
50
+ Attachments: message.attachments.map(
51
+ ({ filename, content, contentType, contentId }) => ({
52
+ FileName: filename,
53
+ RawContent: typeof content === "string" ? utf8(content) : content,
54
+ ...contentType === void 0 ? {} : { ContentType: contentType },
55
+ ...contentId === void 0 ? { ContentDisposition: "ATTACHMENT" } : { ContentDisposition: "INLINE", ContentId: contentId }
56
+ })
57
+ )
58
+ }
59
+ },
60
+ ...configurationSetName === void 0 ? {} : { ConfigurationSetName: configurationSetName },
61
+ ...emailTags(message)
62
+ });
63
+ var createSesProvider = (options = {}) => {
64
+ const { client, clientConfig, configurationSetName } = checkKeys(
65
+ "createSesProvider()",
66
+ options,
67
+ OPTIONS
68
+ );
69
+ if (client !== void 0 && clientConfig !== void 0) {
70
+ throw new TypeError(
71
+ "createSesProvider(): set client or clientConfig, not both."
72
+ );
73
+ }
74
+ if (client !== void 0 && typeof client?.send !== "function") {
75
+ throw new TypeError(
76
+ "createSesProvider(): client must have a send() method."
77
+ );
78
+ }
79
+ if (configurationSetName !== void 0 && (typeof configurationSetName !== "string" || configurationSetName === "")) {
80
+ throw new TypeError(
81
+ "createSesProvider(): configurationSetName must be a non-empty string."
82
+ );
83
+ }
84
+ const ses = client ?? new SESv2Client(clientConfig ?? {});
85
+ return {
86
+ name: "aws-ses",
87
+ send: async (message) => {
88
+ const command = new SendEmailCommand(
89
+ toSendEmailInput(message, configurationSetName)
90
+ );
91
+ let output;
92
+ try {
93
+ output = await ses.send(command);
94
+ } catch (error) {
95
+ const status = error?.$metadata?.httpStatusCode;
96
+ throw new MailSendError("aws-ses", String(error), {
97
+ cause: error,
98
+ status: typeof status === "number" ? status : void 0
99
+ });
100
+ }
101
+ return { messageId: output.MessageId };
102
+ }
103
+ };
104
+ };
105
+ export {
106
+ createSesProvider
107
+ };
108
+ //# sourceMappingURL=aws-ses.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/providers/aws-ses.ts"],"sourcesContent":["import {\n SendEmailCommand,\n SESv2Client,\n type SendEmailCommandInput,\n type SendEmailCommandOutput,\n type SESv2ClientConfig,\n} from '@aws-sdk/client-sesv2'\nimport { base64, utf8 } from '../encoding.js'\nimport { MailSendError } from '../errors.js'\nimport type { MailProvider } from '../mailer.js'\nimport { keyValueTags } from './key-value-tags.js'\nimport {\n checkKeys,\n formatAddress,\n type Address,\n type MailMessage,\n} from '../message.js'\n\n/** An SES client, or any object with the same `send()` method. */\nexport interface SesClient {\n send(command: SendEmailCommand): Promise<SendEmailCommandOutput>\n}\n\n/** Options of `createSesProvider()`. */\nexport interface SesProviderOptions {\n /** Existing client. Defaults to a client created with `clientConfig`. */\n client?: SesClient | undefined\n /** Configuration of the created client: `region`, `credentials`… */\n clientConfig?: SESv2ClientConfig | undefined\n /** SES configuration set (events, dedicated IPs). */\n configurationSetName?: string | undefined\n}\n\nconst OPTIONS = new Set(['client', 'clientConfig', 'configurationSetName'])\n\nconst UTF8 = 'UTF-8'\n\n/**\n * Address for SES: a display name with non-ASCII characters is encoded\n * (RFC 2047 encoded-word), as SES requires.\n */\nconst toSesAddress = (address: Address) =>\n address.name !== undefined && /[^\\x20-\\x7e]/.test(address.name)\n ? `=?UTF-8?B?${base64(address.name)}?= <${address.address}>`\n : formatAddress(address)\n\n/** Tags and metadata as `EmailTags`. */\nconst emailTags = (message: MailMessage) => {\n const tags = keyValueTags(message, 'SES')\n return tags.length > 0\n ? {\n EmailTags: tags.map(({ name, value }) => ({\n Name: name,\n Value: value,\n })),\n }\n : {}\n}\n\n/** `SendEmail` input of a message (simple content). */\nconst toSendEmailInput = (\n message: MailMessage,\n configurationSetName?: string\n): SendEmailCommandInput => ({\n FromEmailAddress: toSesAddress(message.from),\n Destination: {\n ToAddresses: message.to.map(toSesAddress),\n CcAddresses: message.cc.map(toSesAddress),\n BccAddresses: message.bcc.map(toSesAddress),\n },\n ReplyToAddresses: message.replyTo.map(toSesAddress),\n Content: {\n Simple: {\n Subject: { Data: message.subject, Charset: UTF8 },\n Body: {\n Text: { Data: message.text, Charset: UTF8 },\n ...(message.html === undefined\n ? {}\n : { Html: { Data: message.html, Charset: UTF8 } }),\n },\n Headers: Object.entries(message.headers).map(([Name, Value]) => ({\n Name,\n Value,\n })),\n Attachments: message.attachments.map(\n ({ filename, content, contentType, contentId }) => ({\n FileName: filename,\n RawContent: typeof content === 'string' ? utf8(content) : content,\n ...(contentType === undefined ? {} : { ContentType: contentType }),\n ...(contentId === undefined\n ? { ContentDisposition: 'ATTACHMENT' as const }\n : { ContentDisposition: 'INLINE' as const, ContentId: contentId }),\n })\n ),\n },\n },\n ...(configurationSetName === undefined\n ? {}\n : { ConfigurationSetName: configurationSetName }),\n ...emailTags(message),\n})\n\n/**\n * Creates a provider that sends messages with Amazon SES (API v2), through\n * the AWS SDK (`@aws-sdk/client-sesv2`, peer dependency). Credentials and\n * region come from the environment unless `clientConfig` sets them.\n *\n * ```ts\n * const provider = createSesProvider({ clientConfig: { region: 'eu-west-3' } })\n * ```\n */\nexport const createSesProvider = (\n options: SesProviderOptions = {}\n): MailProvider => {\n const { client, clientConfig, configurationSetName } = checkKeys(\n 'createSesProvider()',\n options,\n OPTIONS\n ) as SesProviderOptions\n if (client !== undefined && clientConfig !== undefined) {\n throw new TypeError(\n 'createSesProvider(): set client or clientConfig, not both.'\n )\n }\n if (client !== undefined && typeof client?.send !== 'function') {\n throw new TypeError(\n 'createSesProvider(): client must have a send() method.'\n )\n }\n if (\n configurationSetName !== undefined &&\n (typeof configurationSetName !== 'string' || configurationSetName === '')\n ) {\n throw new TypeError(\n 'createSesProvider(): configurationSetName must be a non-empty string.'\n )\n }\n const ses = client ?? new SESv2Client(clientConfig ?? {})\n\n return {\n name: 'aws-ses',\n send: async (message) => {\n // Limits of SES (MailMessageError) are thrown as they are.\n const command = new SendEmailCommand(\n toSendEmailInput(message, configurationSetName)\n )\n let output: SendEmailCommandOutput\n try {\n output = await ses.send(command)\n } catch (error) {\n const status = (\n error as { $metadata?: { httpStatusCode?: unknown } } | null\n )?.$metadata?.httpStatusCode\n // SDK errors: \"MessageRejected: Email address is not verified.\"\n throw new MailSendError('aws-ses', String(error), {\n cause: error,\n status: typeof status === 'number' ? status : undefined,\n })\n }\n return { messageId: output.MessageId }\n },\n }\n}\n"],"mappings":"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","names":[]}