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.
Quick start
Section titled “Quick start”-
Pick a provider (Postmark, SendGrid or Mailgun), verify your sending domain, and add SPF/DKIM records.
-
Set the environment variables:
EMAIL_PROVIDER=postmarkEMAIL_PROVIDER_API_KEY=your-key# Mailgun only — required when EMAIL_PROVIDER=mailgunEMAIL_MAILGUN_DOMAIN=mg.yourdomain.com -
Run
npm run devand open/_emailto preview templates.
Sending
Section titled “Sending”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).
Templates
Section titled “Templates”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.
Placeholders
Section titled “Placeholders”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.
Where email is sent
Section titled “Where email is sent”| Trigger | Template | Recipient |
|---|---|---|
POST /api/message-us |
contact-notification |
EMAIL_TO_ADDRESS |
Clerk user.created |
welcome |
The new user |
Contact form
Section titled “Contact form”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.
On auto-replying to the sender
Section titled “On auto-replying to the sender”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.
Previewing
Section titled “Previewing”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.
Adding a provider
Section titled “Adding a provider”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.
Design notes
Section titled “Design notes”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.