Frameworks and agents · Published 2026-10-31 · Updated 2026-10-31 · AgentiSend
Send email with RedwoodJS
A RedwoodJS service function that sends one note with the Node SDK and returns the message id or the refusal.
To send email with RedwoodJS, put the send in a service function. Redwood's services guide says a service is an ordinary function, and that GraphQL resolvers are those functions. The function below takes { input }, checks the address, calls emails.send with an idempotency key taken from the address in input, and returns the message id or the refusal's code and fix. The suite calls the function against a local AgentiSend server. It does not boot Redwood.
Store the id the function returns. GET /emails/{id}/explain names the check that held the message instead of delivering it.
Install
pnpm add agentisendName the GraphQL field sendReceipt so Redwood calls the exported function. The services guide's contact form submits input:
mutation SendReceipt($input: SendReceiptInput!) {
sendReceipt(input: $input) {
id
}
}SendReceiptInput is the SDL type you declare for email and note. The page calls create({ variables: { input: data } }) in that guide; pass the same input here.
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
/**
* RedwoodJS — a service function. Redwood's GraphQL resolvers are services,
* and a service is a function that receives `{ input }`.
*
* There is no Redwood package for this API. The function posts through the
* Node SDK. The suite calls it on Node and does not boot Redwood.
*
* 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 sendReceipt(args: {
input: { email?: string; note?: string };
}): Promise<{ status: number; body: Record<string, unknown> }> {
const email = String(args.input.email ?? '').trim();
const note = String(args.input.note ?? '');
if (!ADDRESS.test(email)) {
return { status: 400, body: { error: 'email must be one address, like you@example.com' } };
}
try {
const { id } = await agentisend.emails.send(
{
from: mailFrom(),
to: email,
subject: 'We received your note',
text: `Note: ${note}`,
},
// The person the mutation names, not the moment it ran.
{ idempotencyKey: `redwood/${email}` },
);
return { status: 200, body: { id } };
} catch (err) {
if (err instanceof AgentiSendError) {
return { status: err.status, body: { code: err.code, fix: err.fix } };
}
return { status: 500, body: { error: 'send failed' } };
}
}Try it
Call the service with { input: { email: 'you@example.com', note: 'hello' } }. The GraphQL field returns body.id from that result.
What comes back when it goes wrong
Every refused request carries code, message, fix and docs_url. Return those two fields from the service so the mutation error can show fix without a person reading the prose. The error catalogue lists every code.