Core

Mail

Send emails through a mail driver. Set up Resend with a guided page.

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 variableValue
NUXT_PLUTO_MAIL_DRIVERresend, dev, none, or the id of a registered driver. Empty means automatic.
NUXT_PLUTO_MAIL_RESEND_API_KEYThe Resend API key (re_...).
NUXT_PLUTO_MAIL_FROMThe From address, for example Shop <hello@example.com>.
NUXT_PLUTO_MAIL_REPLY_TOThe Reply-To address. Optional.
NUXT_PLUTO_MAIL_MAILPIT_URLThe 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:

  1. NUXT_PLUTO_MAIL_DRIVER: a registered driver, or resend, dev, none. An unknown id gives none and logs one warning.
  2. resend, when a Resend key resolves.
  3. dev, in development.
  4. none.
Production never falls back to the dev driver. A production site with no key has the none driver.
DriverBehavior
resendSends through the Resend HTTP API.
devIn 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.
noneEvery send throws not_configured. The caller decides what to do.

The Resend driver

  • It uses plain fetch, with no SDK.
  • It sends a User-Agent header. 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:

CodeMeaning
not_configuredNo driver can send.
invalid_keyResend rejected the key.
restricted_keyThe key is send-only and the call needs full access.
test_sender_onlyThe From domain is not verified. Resend sends only to the account owner.
domain_not_verifiedThe From domain is not verified.
invalid_messageThe message or the request is not valid. A line break in a header value is also not valid.
rate_limited, quota_exceededToo many requests, or the plan limit is used.
timeout, network, provider_errorResend 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 be http or https.

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:

  1. Create a Resend account.
  2. 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.
  3. Send a test email to yourself. It works before you verify a domain, with the Resend test sender.
  4. 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.
  5. Set the From address, and an optional Reply-To. The page shows the NUXT_PLUTO_MAIL_FROM line to copy.
  6. Send a test email to anyone.
  7. 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.

RoutePurpose
GET statusThe driver, the From address, and if a key is set. It has no secret.
POST testSends the test email. The limit is 5 calls in 10 minutes for each admin.
POST resend/check-keyChecks the key. full lists the domains. sending means send-only.
POST resend/domainsCreates a domain and returns the DNS records.
GET resend/domains/:idReads a domain with its status and records.
POST resend/domains/:id/verifyAsks Resend to check the DNS now.
POST resend/sending-keyMakes 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 no authorize, or when authorize throws, 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.