Example: sending email
Send transactional email from a SupaHost Edge Function with an email API such as Resend, safely tied to the signed-in user, and trigger it from your app or the database.
SupaHost sends your project's Auth emails (sign-up confirmation, magic links, password resets) for you, using the templates on the Auth tab. Custom SMTP isn't available for those. For your own app's email, such as receipts, reminders and notifications, call an email provider's HTTP API from an Edge Function. This page uses Resend; Postmark, SendGrid and others work the same way with their own endpoints.
The function below sends a booking confirmation to the signed-in user. It only ever emails the caller about their own booking, so it can't be abused to send arbitrary mail.
1. Set up the provider
- Create a Resend account and verify a sending domain (for example
mail.example.com) by adding the DNS records Resend gives you. Until a domain is verified you can only send test mail. - Create an API key with sending access.
- In Dashboard → your project → Functions tab → Secrets, add
RESEND_API_KEYwith that key.
2. The function
Create a function named send-booking-confirmation and keep Require a JWT on.
import { createClient } from "jsr:@supabase/supabase-js@2";
const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY")!;
const FROM = "Harbour Rooms <[email protected]>";
const corsHeaders = {
"Access-Control-Allow-Origin": "https://bookings.example.com",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
function escapeHtml(value: string) {
return value.replace(/[&<>"']/g, (c) =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!,
);
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
const authHeader = req.headers.get("Authorization") ?? "";
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_ANON_KEY")!,
{ global: { headers: { Authorization: authHeader } } },
);
// 1. Who is calling? The anon key alone isn't enough.
const { data: { user } } = await supabase.auth.getUser(authHeader.replace(/^Bearer\s+/i, ""));
if (!user?.email) {
return Response.json({ error: "Sign in first" }, { status: 401, headers: corsHeaders });
}
// 2. Load the booking as that user. RLS returns nothing if it isn't theirs.
const { bookingId } = await req.json().catch(() => ({ bookingId: null }));
const { data: booking, error } = await supabase
.from("bookings")
.select("id, room, starts_at")
.eq("id", bookingId)
.maybeSingle();
if (error || !booking) {
return Response.json({ error: "Booking not found" }, { status: 404, headers: corsHeaders });
}
// 3. Build the message on the server, from trusted data.
const when = new Date(booking.starts_at).toUTCString();
const room = escapeHtml(booking.room);
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: FROM,
to: [user.email],
subject: `Your booking: ${booking.room}`,
html: `<p>Thanks for booking <strong>${room}</strong>.</p><p>Check-in: ${when}</p>`,
text: `Thanks for booking ${booking.room}.\nCheck-in: ${when}`,
}),
});
if (!res.ok) {
console.error("Resend error", res.status, await res.text());
return Response.json({ error: "Couldn't send the email" }, { status: 502, headers: corsHeaders });
}
const { id } = await res.json();
console.log(`Sent confirmation ${id} for booking ${booking.id}`);
return Response.json({ sent: true, id }, { headers: corsHeaders });
});
Deploy it, then call it from your app after the booking is saved:
const { data, error } = await supabase.functions.invoke("send-booking-confirmation", {
body: { bookingId: booking.id },
});
Sending from the database instead
Sometimes the email should go out whenever a row appears, whatever client created it. Two options:
- Database webhook. Add a trigger that calls the function with
net.http_poston insert. Send theservice_rolekey (kept in Vault) as the bearer token, and have the function check the role and look the booking up with an admin client. See Database webhooks and HTTP requests with pg_net. - Queue. Insert a message into a pgmq queue on insert, and have a scheduled function send emails in batches. This survives provider outages, because unsent messages stay in the queue and reappear after their visibility timeout. See Queues with pgmq and Processing queue messages.
For scheduled digests and reminders, see Running functions on a schedule.
Good habits
- Never let callers choose the recipient or the content of mail sent from your domain. Derive both from the signed-in user and your own data.
- Escape anything you put into HTML, even values from your own database.
- Send a plain-text part alongside the HTML; it helps deliverability.
- Log the provider's message id, not the email body. The Logs card is readable by your whole team.
- Keep it fast. The provider usually answers in well under a second, but if you send many messages per request, use a queue so you stay well inside the 150-second response limit.
Other providers
Swap the fetch call for the provider's API: Postmark takes a X-Postmark-Server-Token header at https://api.postmarkapp.com/email, and SendGrid a bearer token at https://api.sendgrid.com/v3/mail/send, each with its own JSON shape. See Email providers (Resend, Postmark, SendGrid).