Frameworks and agents · Published 2026-09-29 · Updated 2026-09-30 · AgentiSend
Send email from Remix
A Remix action that emails one receipt from a form post, with an idempotency key, and returns the id or the code and fix.
To send email from Remix, put the send in the route's action. It runs on the server, reads the form, calls AgentiSend and returns the message id, or the refusal's code and fix. The action below emails a receipt to the person who asked for it. It refuses a malformed address before the API is called, and sends once per address however many times the form is posted. It is run by the test suite against a local AgentiSend server.
Install
npm install agentisendOn a Remix 2 route, type the argument as ActionFunctionArgs from @remix-run/node. The function here takes the Web Request that type already carries, and returns Response.json.
Environment
| Variable | Required | What it is |
|---|---|---|
AGENTISEND_API_KEY | yes | A key with sending_access. |
MAIL_FROM | yes | The From address, on a domain you have verified. |
AGENTISEND_BASE_URL | no | Defaults to https://api.agentisend.com. |
The route
Put it in app/routes/receipt.tsx. A <Form method="post"> posts to it with or without JavaScript.
/**
* Remix — the `action` of `app/routes/receipt.tsx`.
*
* Remix 2 hands the action a Web `Request`. This file uses that and returns
* `Response.json`, which is what `json()` from `@remix-run/node` returns.
* The package is not imported here: the executed function is the action body,
* and a Remix route types the argument as `ActionFunctionArgs`.
*
* The key stays on the server. `AGENTISEND_API_KEY`, `MAIL_FROM`, and
* optionally `AGENTISEND_BASE_URL`.
*/
import { AgentiSend, AgentiSendError } from 'agentisend';
const agentisend = new AgentiSend();
function mailFrom(): string {
const from = process.env.MAIL_FROM;
if (!from) throw new Error('Set MAIL_FROM to an address on a domain you have verified.');
return from;
}
/** Enough to refuse an obvious typo here; the API checks the address properly. */
const ADDRESS = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export async function action({ request }: { request: Request }): Promise<Response> {
const form = await request.formData();
const email = String(form.get('email') ?? '').trim();
const note = String(form.get('note') ?? '');
if (!ADDRESS.test(email)) {
return Response.json({ error: 'email must be one address, like you@example.com' }, { status: 400 });
}
try {
const { id } = await agentisend.emails.send(
{
from: mailFrom(),
to: email,
subject: 'Your receipt',
text: `Receipt note: ${note}`,
},
// The same person, the same receipt: a second submit replays the first.
{ idempotencyKey: `receipt/${email}` },
);
return Response.json({ id });
} catch (err) {
if (err instanceof AgentiSendError) {
return Response.json({ code: err.code, fix: err.fix }, { status: err.status });
}
throw err;
}
}Try it
<form method="post">
<input type="email" name="email" required />
<input type="text" name="note" />
<button type="submit">Email the receipt</button>
</form>What comes back when it goes wrong
Every refused request carries code, message, fix and docs_url. The action answers with the API's status and forwards code and fix. The error catalogue lists every code.