> ## Documentation Index
> Fetch the complete documentation index at: https://ahasend.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# How to Send Email with Remix

> Send transactional email from React Router framework actions and resource routes with the AhaSend SDK, including bounded, replay-safe webhook handling.

Remix has merged into React Router, so this guide uses [React Router Framework Mode](https://reactrouter.com/start/framework/installation) — the direct continuation of the Remix server, loaders, and actions. If your project is still on Remix v2, work through the [upgrade guide](https://reactrouter.com/upgrading/remix) first: the `app/routes.ts` and `./+types/*` APIs below do not exist there.

Email sends belong in server `action` functions, while inbound AhaSend webhooks use a resource route. Keep the SDK client and credentials in server-only modules.

## Prerequisites

* A React Router Framework Mode project deployed to a Node server with server rendering enabled
* An [AhaSend account](https://dash.ahasend.com/user/register) with a verified sending domain
* An [API key](https://dash.ahasend.com/account/-/settings/api-keys) with the `messages:send:{yourdomain.com}` scope for your sending domain, and your account ID

## Install the SDK

<CodeGroup>
  ```bash npm theme={null}
  npm install @ahasend/sdk
  ```

  ```bash pnpm theme={null}
  pnpm add @ahasend/sdk
  ```

  ```bash yarn theme={null}
  yarn add @ahasend/sdk
  ```

  ```bash bun theme={null}
  bun add @ahasend/sdk
  ```
</CodeGroup>

## Configure Environment Variables

Set the credentials in the Node process environment. For local development, load an ignored `.env` file from your server bootstrap or start the dev server with the variables set; in production, use your host's secret storage. Never use a `VITE_` prefix for secrets, because that prefix is for values exposed to browser code.

```bash .env theme={null}
AHASEND_API_KEY=aha-sk-...
AHASEND_ACCOUNT_ID=your-account-uuid
AHASEND_WEBHOOK_SECRET=aha-whsec-...
```

## Create the Client

Create the client once at module scope and reuse it across requests. The `.server.ts` filename makes the build fail if client code imports this module:

```ts app/lib/ahasend.server.ts theme={null}
import { AhaSendClient } from "@ahasend/sdk";

export const ahasend = AhaSendClient.fromEnv();
```

## Register the Routes

Framework Mode routes are configured in `app/routes.ts`. Add the UI route and webhook resource route alongside your existing routes:

```ts app/routes.ts theme={null}
import { type RouteConfig, route } from "@react-router/dev/routes";

export default [
  route("signup", "./routes/signup.tsx"),
  route("webhooks/ahasend", "./routes/webhooks-ahasend.ts"),
] satisfies RouteConfig;
```

## Send an Email from an Action

Server `action` functions can import the client directly. A public form that sends email must bound its request body, validate its fields, and enforce server-side abuse controls. The example calls an application-specific `emailSendRateLimit.take()` backed by a durable, distributed store; implement it before deploying the route. Do not replace it with browser validation or an in-memory counter.

```tsx app/routes/signup.tsx theme={null}
import type { Route } from "./+types/signup";
import { data, Form } from "react-router";
import { AhaSendAPIError } from "@ahasend/sdk";
import { ahasend } from "~/lib/ahasend.server";
import { emailSendRateLimit } from "~/lib/email-send-rate-limit.server";

const MAX_FORM_BYTES = 4_096;

export async function action({ request }: Route.ActionArgs) {
  const mediaType = request.headers
    .get("content-type")
    ?.split(";", 1)[0]
    .trim()
    .toLowerCase();
  if (mediaType !== "application/x-www-form-urlencoded") {
    return data({ error: "Unsupported content type" }, { status: 415 });
  }

  // `null` becomes NaN, so a chunked request that declares no length is
  // refused rather than read unbounded by `formData()`.
  const declaredHeader = request.headers.get("content-length");
  const declaredLength = declaredHeader === null ? NaN : Number(declaredHeader);
  if (
    !Number.isSafeInteger(declaredLength) ||
    declaredLength < 0 ||
    declaredLength > MAX_FORM_BYTES
  ) {
    return data({ error: "Request body too large" }, { status: 413 });
  }

  const form = await request.formData();
  const emailValue = form.get("email");
  const nameValue = form.get("name");

  if (typeof emailValue !== "string" || typeof nameValue !== "string") {
    return data({ error: "Invalid form submission" }, { status: 400 });
  }

  const email = emailValue.trim();
  const name = nameValue.trim();
  if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return data({ error: "Enter a valid email address" }, { status: 400 });
  }
  if (name.length > 200 || /[\r\n]/.test(name)) {
    return data({ error: "Enter a valid name" }, { status: 400 });
  }

  const allowed = await emailSendRateLimit.take({ request, recipient: email });
  if (!allowed) {
    return data({ error: "Try again later" }, { status: 429 });
  }

  try {
    const result = await ahasend.messages.send({
      from: { email: "hello@yourdomain.com", name: "Your App" },
      recipients: [{ email, name }],
      subject: "Welcome to Your App!",
      html_content: "<h1>Welcome aboard 🎉</h1><p>We're glad you're here.</p>",
      text_content: "Welcome aboard! We're glad you're here.",
    });

    const queued = result.data.filter((r) => r.status !== "error").length;
    if (queued === 0) {
      console.warn("AhaSend queued no recipients", {
        rejected: result.data.length,
      });
      return data({ error: "We could not email that address" }, { status: 422 });
    }
    return { queued };
  } catch (err) {
    if (err instanceof AhaSendAPIError) {
      console.error("AhaSend send failed", {
        status: err.status,
        requestId: err.requestId,
        code: err.code,
      });
      return data({ error: "Failed to send email" }, { status: 502 });
    }
    throw err;
  }
}

export default function Signup({ actionData }: Route.ComponentProps) {
  return (
    <main>
      <h1>Send a welcome email</h1>
      <Form method="post">
        <label>
          Email
          <input name="email" type="email" required maxLength={254} />
        </label>
        <label>
          Name
          <input name="name" required maxLength={200} />
        </label>
        <button type="submit">Send welcome email</button>
      </Form>
      {actionData && "error" in actionData ? (
        <p role="alert">{actionData.error}</p>
      ) : actionData ? (
        <p>Welcome email queued.</p>
      ) : null}
    </main>
  );
}
```

The limiter should combine the signals appropriate to your application, such as IP, authenticated account, and normalized recipient, and expire counters in shared storage. Deny the send when the limiter is unavailable if allowing an unbounded mail relay would be worse than temporarily rejecting signups.

`MAX_FORM_BYTES` plus the required `Content-Length` header is the whole bound: a chunked request that declares no length is refused, and Node stops reading a declared body at its declared size. Rejecting anything that is not `application/x-www-form-urlencoded` keeps a large multipart upload from reaching this route at all. Set a matching or lower limit at your reverse proxy as well.

A 202 is a **multi-status** response: `result.data` holds one entry per recipient, and an individual recipient can come back with `status: "error"` and a null `id` (a suppressed address, for example) while the call itself succeeds. Inspect every entry, not just the first — a resolved promise with nothing queued is a failed send, so the action above reports it as one instead of rendering a silent success.

The SDK retries transient failures automatically, but a retry after a 5xx can still send twice. When a duplicate would be expensive, pass a stable key from the committed business operation as the second argument: `ahasend.messages.send(message, { idempotencyKey: "welcome-" + signup.id })`. Do not derive it from an arbitrary retry attempt.

Add `sandbox: true` to the send request to validate it without delivering anything.

## Handle Webhooks

Read the secret and build the verifier in a `.server.ts` module, not in the route file. Route modules are referenced by **both** the client and the server module graph — React Router strips their `loader` and `action` exports from the browser build, but top-level statements with side effects survive, so a secret check written at the top of a route module is emitted into a client chunk that throws in the browser:

```ts app/lib/ahasend-webhooks.server.ts theme={null}
import { WebhookVerifier } from "@ahasend/sdk/webhooks";

const webhookSecret = process.env.AHASEND_WEBHOOK_SECRET;
if (!webhookSecret) throw new Error("AHASEND_WEBHOOK_SECRET is required");

export const verifier = new WebhookVerifier(webhookSecret);
```

Then handle webhooks in a resource route. Signature verification needs the exact raw bytes, and the body must be bounded while it is read so concurrent requests cannot allocate unbounded memory. This example uses a 1 MB application limit; configure the same or a lower limit at your reverse proxy and cap concurrent requests.

```ts app/routes/webhooks-ahasend.ts theme={null}
import type { Route } from "./+types/webhooks-ahasend";
import { AhaSendWebhookVerificationError } from "@ahasend/sdk/webhooks";
import { verifier } from "~/lib/ahasend-webhooks.server";
import { webhookDeliveries } from "~/lib/webhook-deliveries.server";

const MAX_BODY_BYTES = 1_000_000;

class BodyTooLargeError extends Error {}

async function readLimitedBody(request: Request): Promise<Uint8Array> {
  const declaredLength = request.headers.get("content-length");
  if (declaredLength !== null) {
    const length = Number(declaredLength);
    if (Number.isFinite(length) && length > MAX_BODY_BYTES) {
      throw new BodyTooLargeError();
    }
  }

  if (!request.body) return new Uint8Array();

  const reader = request.body.getReader();
  const chunks: Uint8Array[] = [];
  let total = 0;

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    total += value.byteLength;
    if (total > MAX_BODY_BYTES) {
      try {
        await reader.cancel();
      } catch {
        // The 413 response remains the useful result if cancellation fails.
      }
      throw new BodyTooLargeError();
    }
    chunks.push(value);
  }

  const body = new Uint8Array(total);
  let offset = 0;
  for (const chunk of chunks) {
    body.set(chunk, offset);
    offset += chunk.byteLength;
  }
  return body;
}

export async function action({ request }: Route.ActionArgs) {
  let rawBody: Uint8Array;
  try {
    rawBody = await readLimitedBody(request);
  } catch (error) {
    if (error instanceof BodyTooLargeError) {
      return new Response(null, { status: 413 });
    }
    throw error;
  }

  let event;
  try {
    event = await verifier.parse(request.headers, rawBody);
  } catch (error) {
    if (error instanceof AhaSendWebhookVerificationError) {
      return new Response(null, { status: 400 });
    }
    throw error;
  }

  const webhookId = request.headers.get("webhook-id");
  if (!webhookId) return new Response(null, { status: 400 });

  const accepted = await webhookDeliveries.enqueueOnce(webhookId, event);
  return new Response(null, { status: accepted ? 202 : 200 });
}
```

`enqueueOnce()` must atomically commit both the unique `webhook-id` and a durable job or outbox record. It returns `false` only when that ID was already committed. Let other storage failures throw so the resource route returns `500` and AhaSend can retry. Process the durable job with idempotent side effects outside the request; do not log the raw body, signature, secret, event, or recipient data.

Create the webhook in your [AhaSend dashboard](https://dash.ahasend.com) pointing at `https://your-app.com/webhooks/ahasend`, and copy its secret into `AHASEND_WEBHOOK_SECRET` exactly as shown (including the `aha-whsec-` prefix).

## Going Further

* **Deployment**: deploy the server build to a Node host and inject the three `AHASEND_*` variables through that host's secret storage. A static-only deployment cannot run server actions or receive webhooks.
* **Templating**: pass `substitutions` per recipient and use `{{ variable }}` in the subject or body.
* **Batch sends**: `recipients` accepts up to 100 entries; each gets a separate, individually-substituted message.
* **Scheduling**: set `schedule.first_attempt` to a future RFC 3339 timestamp within seven days of the request.
* **Attachments**: pass `attachments: [{ data, content_type, file_name, base64: true }]`. Set `base64: true` for binary files such as PDFs.

See the [API reference](/docs/api-reference) for every endpoint the SDK exposes. The same server-side pattern works in the other full-stack frameworks: see the [Next.js](/docs/guides/nextjs) and [SvelteKit](/docs/guides/sveltekit) guides.

## Troubleshooting

<AccordionGroup>
  <Accordion title="Build error: server-only module referenced by client">
    Client code imported `ahasend.server.ts` directly. Keep the SDK client in `.server.ts` modules and import it only from server exports such as `action` and `loader`.
  </Accordion>

  <Accordion title="The new route returns 404">
    A file under `app/routes` is not registered automatically unless the project explicitly uses the file-routes convention. Add the route module to `app/routes.ts`, then run `react-router routes` to inspect the configured route tree.
  </Accordion>

  <Accordion title="Webhook verification always returns 400">
    The body passed to `verifier.parse()` must be the exact bytes AhaSend sent. Do not call `request.json()`, `request.text()`, or another body reader first, because a request body can only be read once. Also confirm `AHASEND_WEBHOOK_SECRET` includes the `aha-whsec-` prefix.
  </Accordion>

  <Accordion title="401 AhaSendAuthenticationError">
    The API key is missing, malformed, or revoked. Verify `AHASEND_API_KEY` is set in the server environment and that the key exists in your [dashboard](https://dash.ahasend.com/account/-/settings/api-keys).
  </Accordion>
</AccordionGroup>
