Frameworks and agents · Published 2026-10-31 · Updated 2026-10-31 · AgentiSend
Send email with Supabase Edge Functions
A Supabase Edge Function that posts one invite with fetch. This is not the Send Email Hook.
To send email with Supabase Edge Functions, put a fetch handler in supabase/functions/send/index.ts and post JSON to POST /emails. This page is that HTTP function. The Send Email Hook is a different integration: Supabase Auth calls it for sign-up and recovery mail. An Edge Function is a function you invoke.
The secrets guide reads a value with Deno.env.get and serves the file with Deno.serve. Locally the function reads supabase/functions/.env, which that guide says to keep out of git. On the hosted project, set the same names under Edge Function Secrets, or with supabase secrets set. A name that starts with the reserved Supabase prefix is rejected. The handler below checks the address, sends an invite with an idempotency key derived from the recipient, and returns the message id or the refusal's code and fix. The suite calls handle against a local AgentiSend server.
The generated supabase functions new template wraps fetch and can require a project key. Use that wrapper when the function should refuse anonymous calls. The send itself is handle.
Install
No package to add. The handler uses fetch.
Deno.serve(handle);Create the function with supabase functions new send, replace the template body with this file, and deploy with supabase functions deploy send. The quickstart serves it locally at http://127.0.0.1:54321/functions/v1/send after supabase functions serve send.
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
/**
* Supabase Edge Functions — an HTTP function, not the Send Email Hook.
*
* Save this file as `supabase/functions/send/index.ts` and serve it with
* `Deno.serve(handle)`, which is how the secrets guide reads `Deno.env.get`.
* The function posts JSON to POST /emails. It also runs on Node, which is
* how the suite executes it: `process.env` is used when `Deno.env` is absent.
*
* AGENTISEND_API_KEY, MAIL_FROM, and optionally AGENTISEND_BASE_URL.
*/
function env(name: string): string | undefined {
const fromProcess = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.[
name
];
const deno = (globalThis as { Deno?: { env: { get(key: string): string | undefined } } }).Deno;
return fromProcess ?? deno?.env.get(name);
}
function mailFrom(): string {
const from = env('MAIL_FROM');
if (!from) throw new Error('Set MAIL_FROM to an address on a domain you have verified.');
return from;
}
function baseUrl(): string {
return (env('AGENTISEND_BASE_URL') ?? 'https://api.agentisend.com').replace(/\/+$/, '');
}
/** Enough to refuse an obvious typo here; the API checks the address properly. */
const ADDRESS = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export async function handle(request: Request): Promise<Response> {
let email = '';
let note = '';
try {
const parsed = (await request.json()) as { email?: string; note?: string };
email = String(parsed.email ?? '').trim();
note = String(parsed.note ?? '');
} catch {
return Response.json({ error: 'email must be one address, like you@example.com' }, { status: 400 });
}
if (!ADDRESS.test(email)) {
return Response.json({ error: 'email must be one address, like you@example.com' }, { status: 400 });
}
const response = await fetch(`${baseUrl()}/emails`, {
method: 'POST',
headers: {
authorization: `Bearer ${env('AGENTISEND_API_KEY') ?? ''}`,
'content-type': 'application/json',
accept: 'application/json',
// The recipient, not the moment: a retry of the same note replays.
'idempotency-key': `invite/${email}`,
},
body: JSON.stringify({
from: mailFrom(),
to: email,
subject: 'You are invited',
text: `Invite note: ${note}`,
}),
});
const parsed = (await response.json()) as { id?: string; error?: { code?: string; fix?: string } };
if (!response.ok) {
return Response.json(
{ code: parsed.error?.code, fix: parsed.error?.fix },
{ status: response.status },
);
}
return Response.json({ id: parsed.id });
}Try it
curl -X POST http://127.0.0.1:54321/functions/v1/send \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","note":"project"}'The response is {"id":"…"}. That id is the invite in GET /emails/{id}.
What comes back when it goes wrong
Every refused request carries code, message, fix and docs_url. The handler returns those fields with the API's status. A body that is not JSON never reaches POST /emails. The error catalogue lists every code.
Next
- Send email from Supabase is the Send Email Hook
- Send email with Deno Deploy
- Quickstart