Envello
Tutorial

Sending transactional email from Remix

Envello Team·2026-07-17·7 min read

Remix apps can deploy to Node or to edge runtimes (Cloudflare Pages, Deno Deploy), so the safest integration is fetch, which works identically across all of them, rather than a Node-specific SDK that might not run on an edge deploy target.

Send it from an action

Transactional sends belong in a Remix action (server-only code triggered by a form submission or mutation), never in a loader and never from client-side code, since both of those run in contexts where you either shouldn't be doing side effects (loaders) or would expose your API key (the client):

  • export async function action({ request }: ActionFunctionArgs) { const res = await fetch('https://api.envello.dev/emails', { method: 'POST', headers: { Authorization: `Bearer ${process.env.ENVELLO_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ from, to, subject, html }) }); if (!res.ok) return json({ error: 'send failed' }, { status: 502 }); return json({ ok: true }); }

Creating a reusable email module

Put the send logic in a server-only module so it's shared across every action that needs it. Name the file with a .server.ts suffix so Remix's compiler excludes it from the client bundle entirely.

  • Create app/utils/email.server.ts
  • Export an async sendEmail({ to, subject, html, text? }) function
  • The .server.ts suffix is a Remix convention: any file ending in .server enforces server-only usage at build time, so accidentally importing it from a client component fails the build rather than leaking your API key

Form validation with Remix's action pattern

Validate the form data before attempting a send, and return field-level errors that the UI can display without a page reload.

  • export async function action({ request }: ActionFunctionArgs) { const formData = await request.formData(); const email = formData.get('email'); if (typeof email !== 'string' || !email.includes('@')) return json({ errors: { email: 'Invalid email' } }, { status: 400 }); ... }
  • On success, call sendEmail() and return json({ success: true })
  • In the component, use useActionData() to read the response and display errors or a success message
  • Use useNavigation().state === 'submitting' to show a pending UI state during the send

Error handling

Handle the API response explicitly rather than assuming success whenever fetch doesn't throw.

  • !res.ok: read the error body with await res.json() for the specific failure reason
  • 400: return json({ error: 'Invalid recipient or unverified domain' }, { status: 400 })
  • 429: return json({ error: 'Too many requests, try again shortly' }, { status: 429 })
  • 5xx: retry once with a short delay; if still failing, return json({ error: 'Email service unavailable' }, { status: 502 })
  • Wrap the fetch call in try/catch for network errors (timeouts, DNS failures) and return a 502 in the catch block

Not blocking the response

For emails that don't need to complete before the user sees a result (a welcome email after signup, where the signup itself already succeeded), don't await the send inside the action if your deploy target supports background execution.

On Node with a long-running server, you can fire the send without awaiting it, but be aware the request may complete and the process could still be mid-flight on the promise if not handled carefully. The more reliable pattern for genuinely fire-and-forget sends is a queue (Redis-backed, e.g. BullMQ) that the action enqueues to and a separate worker processes, so the send survives even if the web process cycles.

For anything the user needs confirmation of (password reset, order confirmation), await the send in the action so you can return an accurate success or failure state.

Environment variables across deploy targets

process.env works as expected on a Node deploy target. On Cloudflare Pages or other edge runtimes, environment variables typically come through a context object instead (context.env in Cloudflare's case), check your specific adapter's docs for exactly how secrets are exposed, since this is the one part of the integration that genuinely differs by deploy target even though the fetch call itself doesn't.

For Cloudflare Pages: export async function action({ context }: ActionFunctionArgs) { const apiKey = context.env.ENVELLO_API_KEY; ... }

For Deno Deploy: use Deno.env.get('ENVELLO_API_KEY') instead of process.env

Webhook handling

Create a resource route (a route with no default export, just a loader/action) to receive Envello's delivery event webhooks.

  • Create app/routes/webhooks.envello.tsx with only an action export (no component, making it a resource route)
  • Read the raw body: const rawBody = await request.text()
  • Get the signature: request.headers.get('x-envello-signature')
  • Verify HMAC-SHA256 with Node's crypto module (or Web Crypto on edge runtimes)
  • Parse the event and handle: delivered (log), bounced (suppress in your database), complained (suppress)
  • Return new Response('ok', { status: 200 })

Testing

Mock the email.server.ts module in tests using your test framework's module mocking (vi.mock in Vitest, jest.mock in Jest). Verify the action calls sendEmail with the correct arguments and returns the expected response shape for both success and error paths.

For end-to-end tests, use Envello's test mode so the real API is called but no email is delivered, letting you verify the full form-submission-to-API-call path.

Free tool

Check your domain's SPF, DKIM, and DMARC records

Paste in a domain and see what's missing, plus the exact DNS records to fix it. Free, no account needed.

Check your domain →
Envello

EU-hosted transactional email, done right by default.