Pluto sends its own emails through a mail driver. The first emails are the test email and the invitation. The driver is a contract, so a site can change the provider with no change to the rest of the site.
Core gives the contract, three drivers, two email templates, a server translator, and a guided setup page at Settings, Email.
Configuration
All settings are private runtimeConfig.plutoMail values. An environment variable sets each one at run time. Do not put them in the build environment.
| Environment variable | Value |
|---|---|
NUXT_PLUTO_MAIL_DRIVER | resend, dev, none, or the id of a registered driver. Empty means automatic. |
NUXT_PLUTO_MAIL_RESEND_API_KEY | The Resend API key (re_...). |
NUXT_PLUTO_MAIL_FROM | The From address, for example Shop <hello@example.com>. |
NUXT_PLUTO_MAIL_REPLY_TO | The Reply-To address. Optional. |
NUXT_PLUTO_MAIL_MAILPIT_URL | The URL of a Mailpit server, for example http://localhost:8025. Optional. |
Pluto keeps every secret in the environment. Nothing stores a key in the database, in the settings table, or in the Git CMS repository. A layer can register a secret store with registerMailStore. The environment always wins over a store.
Choose the driver
Pluto picks the driver in this order:
NUXT_PLUTO_MAIL_DRIVER: a registered driver, orresend,dev,none. An unknown id givesnoneand logs one warning.resend, when a Resend key resolves.dev, in development.none.
dev driver. A production site with no key has the none driver.| Driver | Behavior |
|---|---|
resend | Sends through the Resend HTTP API. |
dev | In development, it prints the email to the server console, with the text version. You can copy an invite link from it. With NUXT_PLUTO_MAIL_MAILPIT_URL, it also posts the email to Mailpit. Outside development, it logs nothing. Without Mailpit, it throws not_configured. |
none | Every send throws not_configured. The caller decides what to do. |
The Resend driver
- It uses plain
fetch, with no SDK. - It sends a
User-Agentheader. Resend refuses a call without one. - Each attempt has a 4 second timeout.
- Each send has an
Idempotency-Key. - It retries once with the same key, after a network error, a timeout, or a 5xx answer.
- With no From address, it uses
Pluto <onboarding@resend.dev>. Resend delivers that address only to the email of the Resend account, until you verify a domain.
Send an email
const email = await renderPlutoEmail('invite', { url, inviterName, roles }, { locale })
await sendPlutoMail({ to, ...email })
sendPlutoMail throws a PlutoMailError with a code:
| Code | Meaning |
|---|---|
not_configured | No driver can send. |
invalid_key | Resend rejected the key. |
restricted_key | The key is send-only and the call needs full access. |
test_sender_only | The From domain is not verified. Resend sends only to the account owner. |
domain_not_verified | The From domain is not verified. |
invalid_message | The message or the request is not valid. A line break in a header value is also not valid. |
rate_limited, quota_exceeded | Too many requests, or the plan limit is used. |
timeout, network, provider_error | Resend did not answer, was not reachable, or failed. |
The error message is fixed English text. It never repeats the provider text, a recipient, a subject, a link, or a key. Mail code never logs these values. The one exception is the dev driver, which prints the email in development on purpose.
sendPlutoMail also refuses a message with no valid recipient. It refuses a subject, address, or Reply-To with a line break.
Templates
An email template is a plain TypeScript function that returns { subject, html, text }. There are two templates: test and invite. renderPlutoEmail(name, data, { locale?, siteName? }) renders one.
- The texts are the
pluto.mail.*keys of the locale files. An app can override them. - The layout is a 600 px table with inline CSS, a button, and the plain link under it. It has a footer with the site name. It has no image and no tracking.
- Every text goes through
escapeHtml. A button URL must behttporhttps.
The server translator
usePlutoServerTranslator(locale) returns { t, te, tx, locale } for a server route. It uses the same locale files as the admin. An unknown locale and a missing key fall back to the default locale.
The Email settings page
Settings, Email is for administrators only. It is a guided flow.
A status card shows first: the driver, if a key is set, and the From address. Then seven steps:
- Create a Resend account.
- Add the API key. The page shows the line
NUXT_PLUTO_MAIL_RESEND_API_KEY=...with a copy button. It checks if the key has full access or is send-only. - Send a test email to yourself. It works before you verify a domain, with the Resend test sender.
- Add a domain. Enter the name and the region. The page shows the DNS records with copy buttons. Verify asks Resend to check. The page then checks the status every 10 seconds, then slower up to 60 seconds. It stops after 10 minutes. Check again starts it again.
- Set the From address, and an optional Reply-To. The page shows the
NUXT_PLUTO_MAIL_FROMline to copy. - Send a test email to anyone.
- Optional: make a send-only key for the domain, to replace the setup key.
Add a section
A layer adds a section below the steps with mailPanels:
definePlutoExtension({
id: 'supabase',
mailPanels: [
{ id: 'auth-emails', title: 'supabase.mail.authEmails.title', component: AuthEmailsPanel }
]
})
title and description are a key or a literal. Register the component with defineAsyncComponent.
Admin routes
All routes are under /api/_pluto/mail/. They are for administrators only.
| Route | Purpose |
|---|---|
GET status | The driver, the From address, and if a key is set. It has no secret. |
POST test | Sends the test email. The limit is 5 calls in 10 minutes for each admin. |
POST resend/check-key | Checks the key. full lists the domains. sending means send-only. |
POST resend/domains | Creates a domain and returns the DNS records. |
GET resend/domains/:id | Reads a domain with its status and records. |
POST resend/domains/:id/verify | Asks Resend to check the DNS now. |
POST resend/sending-key | Makes a send-only key. It returns the new key once. |
- The routes fail closed. They call
adapter.authorize(event, '*'). With no content adapter, with an adapter that has noauthorize, or whenauthorizethrows, the answer is 403. - A route that changes state also refuses a cross-site request.
- Keys never reach the browser. The one key in a response is the new send-only key, shown once.
- The test email limit is in memory, so each server instance counts for itself.
Add a mail driver
See Add a mail driver.
Limits
- Pluto keeps the Resend key in the environment only. No layer ships a vault store yet.
- Only Resend has a guided setup. Another provider needs its own driver.
- Supabase sign-up, password reset, and magic link emails still go through the Supabase mailer. See Invites and email.