Skip to content

Email

Email is handled by astro-email, a project-local Astro integration in src/integrations/email/. Templates live in the repository, compile during astro build, and are inlined into the server bundle — nothing is read from disk at request time, so the same build runs on the netlify, node and vercel adapters.

  1. Pick a provider (Postmark, SendGrid or Mailgun), verify your sending domain, and add SPF/DKIM records.

  2. Set the environment variables:

    EMAIL_PROVIDER=postmark
    EMAIL_PROVIDER_API_KEY=your-key
    # Mailgun only — required when EMAIL_PROVIDER=mailgun
    EMAIL_MAILGUN_DOMAIN=mg.yourdomain.com
  3. Run npm run dev and open /_email to preview templates.

import { isEmailConfigured, sendEmail } from '#utils/email'
const result = await sendEmail({
template: 'welcome',
subject: 'Welcome',
parameters: { name: 'Ada', dashboardUrl: 'https://example.com/dashboard' },
})
if (!result.sent) {
// 'not-configured' | 'unknown-template' | 'provider-error' | 'network-error'
}

sendEmail never throws and never rejects; each call site decides what a failed send means. For the Clerk webhook the primary work — syncing the user — has already succeeded, so a mail failure must not turn that into an error response. For the contact form the email is the delivery, so a failed send is reported to the submitter (see Contact form).

  • Directoryemails/
    • Directorycontact-notification/
      • index.html
    • Directorywelcome/
      • index.html

Each directory is one template, named by the directory. The file must be index.html, or index.mjml if you install mjml as a dev dependency.

Template names are type-checked. astro:config:done generates an EmailTemplate union from the directories that exist, so a typo or a deleted template is a compile error rather than a runtime one. After adding a template, run npx astro sync to regenerate the union.

Use {{ name }}. Values are always HTML-escaped, and there is deliberately no unescaped form.

Arrays are joined with a comma and a space. An unknown placeholder renders as empty rather than throwing, so a template and a call site drifting apart degrades the email instead of breaking the request.

Trigger Template Recipient
POST /api/message-us contact-notification EMAIL_TO_ADDRESS
Clerk user.created welcome The new user

POST /api/message-us — the endpoint behind the /message-us form — stores nothing. Each submission is delivered only as the contact-notification email, so the form requires email to be configured: EMAIL_PROVIDER, EMAIL_PROVIDER_API_KEY, EMAIL_FROM_ADDRESS and EMAIL_TO_ADDRESS (plus EMAIL_MAILGUN_DOMAIN for Mailgun).

Situation Response
Email not configured 503 — the contact form is not configured
Provider rejects or fails 502 — the submitter is asked to try again
Notification sent 200 with { success: true, message }

The endpoint keeps its CSRF check, input sanitization and per-IP rate limit. It does not record the submitter’s IP address or user agent, and the success response carries no message ID. GET /api/message-us reports whether email is configured (configured: true | false).

The other contact pages do not use this endpoint: /contact-us submits through Netlify Forms and /contact posts to /success.

The contact notification is sent from EMAIL_FROM_ADDRESS with Reply-To set to the submitter, so replying reaches them. The submitter’s address is unverified, which is exactly why it is the reply address and never the sender — letting a public form choose who your domain sends as is a spoofing vector.

Values bound for a header (subject, to, from, cc, bcc, replyTo) have CR/LF stripped in sendEmail before they reach a provider. sanitizeName collapses runs of whitespace but leaves a lone newline intact, so a submitted subject can carry one; header injection is defended here rather than trusted to three provider APIs.

There is deliberately no confirmation email back to the contact-form submitter. That address is unverified, which would make the endpoint a reflector: anyone could submit the form with a victim’s address and have your domain mail them. The per-IP rate limiter on POST /api/message-us and the honeypot field reduce the volume but do not remove the problem. If you add one, keep the template free of submitter-controlled content beyond a name, so a reflected message cannot carry an attacker’s payload.

npm run dev, then /_email for the index and /_email/<name> for one template, rendered with [placeholder] stand-ins.

The preview route is injected only when command === 'dev', so it cannot reach a deployed environment — including deploy previews.

Add an adapter to src/integrations/email/providers.ts and extend ProviderName. Each adapter is a single fetch returning { ok: true } or { ok: false, status, detail }; adapters report failure rather than throwing.

Templates reach the runtime through a virtual module, virtual:astro-email/templates, built by a Vite plugin the integration registers in astro:config:setup. That is what removes the filesystem read, and with it the per-adapter file-inclusion configuration a disk-reading approach would need.

For why this exists instead of Netlify’s email integration — including the deploy-preview secret exposure that ruled that option out — see project-docs/04-integrations/netlify-email/README.md.