Back to Blog

How to Send Emails from Supabase Edge Functions (2026 Guide)

18 min read

Most Supabase tutorials show you how to set up auth and a database. They skip over a critical piece: sending emails from your backend. Supabase's built-in auth emails are limited — you can't customize them much, and they don't cover transactional emails like receipts, team invites, or usage alerts.

The real answer is Edge Functions. They're Deno-based serverless functions that run server-side, have access to secrets, and can be triggered by HTTP requests, database changes, or auth events. This guide covers the full picture: building email-sending Edge Functions, triggering them from database webhooks and auth hooks, sharing code between functions, building HTML templates, handling errors, and scaling to production. If you're weighing whether to build or buy your email infrastructure, this guide covers the DIY approach.

Pick an Email Provider

Every code example below lets you switch between three providers:

  • Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, and subscriber management from one API. Native Stripe integration. If you're building a SaaS product on Supabase, this saves you from wiring together multiple tools later.
  • Resend is a developer-friendly transactional email API. Clean DX, good docs, solid deliverability. No automations or sequences.
  • SendGrid is the enterprise standard. Feature-rich but a bigger API surface. Good for high volume.

Create Your First Edge Function

supabase functions new send-email

This creates supabase/functions/send-email/index.ts. Edge Functions use the Deno runtime — no node_modules, no package.json. You use Deno.serve() and fetch() for HTTP, and Deno.env.get() for secrets.

Set Your API Key

Store your provider API key as a Supabase secret. This keeps it out of your code and available to all Edge Functions:

Terminal
supabase secrets set SEQUENZY_API_KEY=sq_your_api_key_here
Terminal
supabase secrets set RESEND_API_KEY=re_your_api_key_here
Terminal
supabase secrets set SENDGRID_API_KEY=SG.your_api_key_here

For local development, create a .env.local file in your supabase directory:

supabase/.env.local
SEQUENZY_API_KEY=sq_your_api_key_here
supabase/.env.local
RESEND_API_KEY=re_your_api_key_here
supabase/.env.local
SENDGRID_API_KEY=SG.your_api_key_here

Send Your First Email

The simplest possible Edge Function — accepts a POST request and sends an email.

supabase/functions/send-email/index.ts
import "jsr:@supabase/functions-js/edge-runtime.d.ts";

Deno.serve(async (req) => {
if (req.method !== "POST") {
  return new Response("Method not allowed", { status: 405 });
}

const { to, subject, body } = await req.json();

const response = await fetch("https://api.sequenzy.com/v1/transactional/send", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${Deno.env.get("SEQUENZY_API_KEY")}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ to, subject, body }),
});

if (!response.ok) {
  const error = await response.json();
  return Response.json({ error }, { status: response.status });
}

const result = await response.json();
return Response.json(result);
});
supabase/functions/send-email/index.ts
import "jsr:@supabase/functions-js/edge-runtime.d.ts";

Deno.serve(async (req) => {
if (req.method !== "POST") {
  return new Response("Method not allowed", { status: 405 });
}

const { to, subject, html } = await req.json();

const response = await fetch("https://api.resend.com/emails", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${Deno.env.get("RESEND_API_KEY")}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: "Your App <noreply@yourdomain.com>",
    to,
    subject,
    html,
  }),
});

if (!response.ok) {
  const error = await response.json();
  return Response.json({ error }, { status: response.status });
}

const result = await response.json();
return Response.json(result);
});
supabase/functions/send-email/index.ts
import "jsr:@supabase/functions-js/edge-runtime.d.ts";

Deno.serve(async (req) => {
if (req.method !== "POST") {
  return new Response("Method not allowed", { status: 405 });
}

const { to, subject, html } = await req.json();

const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${Deno.env.get("SENDGRID_API_KEY")}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    personalizations: [{ to: [{ email: to }] }],
    from: { email: "noreply@yourdomain.com" },
    subject,
    content: [{ type: "text/html", value: html }],
  }),
});

if (!response.ok) {
  const error = await response.text();
  return Response.json({ error }, { status: response.status });
}

return Response.json({ sent: true });
});

Deploy and test it:

# Deploy
supabase functions deploy send-email
 
# Test locally
supabase functions serve send-email --env-file supabase/.env.local
 
# Call it
curl -X POST http://localhost:54321/functions/v1/send-email \
  -H "Authorization: Bearer YOUR_ANON_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to": "user@example.com", "subject": "Test", "body": "<p>Hello!</p>"}'

Shared Email Module

When you have multiple Edge Functions that send emails, you don't want to duplicate the sending logic. Supabase Edge Functions support shared modules in the supabase/functions/_shared/ directory.

supabase/functions/_shared/email.ts
interface SendEmailParams {
to: string;
subject: string;
body: string;
}

interface SendEmailResult {
success: boolean;
jobId?: string;
error?: string;
}

export async function sendEmail(params: SendEmailParams): Promise<SendEmailResult> {
const apiKey = Deno.env.get("SEQUENZY_API_KEY");
if (!apiKey) {
  return { success: false, error: "SEQUENZY_API_KEY not set" };
}

const response = await fetch("https://api.sequenzy.com/v1/transactional/send", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${apiKey}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify(params),
});

if (!response.ok) {
  const error = await response.json();
  return { success: false, error: error.message ?? "Send failed" };
}

const result = await response.json();
return { success: true, jobId: result.jobId };
}

export async function sendEmailOrThrow(params: SendEmailParams): Promise<string> {
const result = await sendEmail(params);
if (!result.success) {
  throw new Error(result.error ?? "Email send failed");
}
return result.jobId!;
}
supabase/functions/_shared/email.ts
interface SendEmailParams {
to: string;
subject: string;
html: string;
from?: string;
}

interface SendEmailResult {
success: boolean;
id?: string;
error?: string;
}

export async function sendEmail(params: SendEmailParams): Promise<SendEmailResult> {
const apiKey = Deno.env.get("RESEND_API_KEY");
if (!apiKey) {
  return { success: false, error: "RESEND_API_KEY not set" };
}

const response = await fetch("https://api.resend.com/emails", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${apiKey}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: params.from ?? "Your App <noreply@yourdomain.com>",
    to: params.to,
    subject: params.subject,
    html: params.html,
  }),
});

if (!response.ok) {
  const error = await response.json();
  return { success: false, error: error.message ?? "Send failed" };
}

const result = await response.json();
return { success: true, id: result.id };
}

export async function sendEmailOrThrow(params: SendEmailParams): Promise<string> {
const result = await sendEmail(params);
if (!result.success) {
  throw new Error(result.error ?? "Email send failed");
}
return result.id!;
}
supabase/functions/_shared/email.ts
interface SendEmailParams {
to: string;
subject: string;
html: string;
from?: string;
}

interface SendEmailResult {
success: boolean;
error?: string;
}

export async function sendEmail(params: SendEmailParams): Promise<SendEmailResult> {
const apiKey = Deno.env.get("SENDGRID_API_KEY");
if (!apiKey) {
  return { success: false, error: "SENDGRID_API_KEY not set" };
}

const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${apiKey}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    personalizations: [{ to: [{ email: params.to }] }],
    from: { email: params.from ?? "noreply@yourdomain.com" },
    subject: params.subject,
    content: [{ type: "text/html", value: params.html }],
  }),
});

if (!response.ok) {
  const error = await response.text();
  return { success: false, error };
}

return { success: true };
}

export async function sendEmailOrThrow(params: SendEmailParams): Promise<void> {
const result = await sendEmail(params);
if (!result.success) {
  throw new Error(result.error ?? "Email send failed");
}
}

Now any Edge Function can import it:

import { sendEmail } from "../_shared/email.ts";

Build HTML Email Templates

Raw HTML strings in your Edge Functions get messy fast. Since Edge Functions run Deno, you can use template functions with proper escaping.

Template Functions

Create a shared template module that generates email HTML:

// supabase/functions/_shared/templates.ts
 
function escapeHtml(text: string): string {
  return text
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}
 
function layout(content: string): string {
  return `<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0;padding:0;background-color:#f6f9fc;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
  <table role="presentation" width="100%" cellpadding="0" cellspacing="0">
    <tr>
      <td align="center" style="padding:40px 20px;">
        <table role="presentation" width="480" cellpadding="0" cellspacing="0"
               style="background-color:#ffffff;border-radius:8px;overflow:hidden;">
          <tr>
            <td style="padding:40px;">
              ${content}
            </td>
          </tr>
        </table>
        <p style="color:#9ca3af;font-size:12px;margin-top:24px;">
          &copy; ${new Date().getFullYear()} Your App. All rights reserved.
        </p>
      </td>
    </tr>
  </table>
</body>
</html>`;
}
 
function button(text: string, href: string): string {
  return `<a href="${href}"
    style="display:inline-block;background-color:#f97316;color:#ffffff;padding:12px 24px;
           border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;margin-top:16px;">
    ${escapeHtml(text)}
  </a>`;
}
 
export function welcomeEmail(name: string, loginUrl: string): string {
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">
      Welcome, ${escapeHtml(name)}
    </h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Your account is ready. Log in and start exploring.
    </p>
    ${button("Go to Dashboard", loginUrl)}
  `);
}
 
export function passwordResetEmail(resetUrl: string): string {
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">
      Reset Your Password
    </h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Click the button below to reset your password. This link expires in 1 hour.
    </p>
    ${button("Reset Password", resetUrl)}
    <p style="font-size:14px;color:#6b7280;margin-top:24px;">
      If you didn't request this, you can safely ignore this email.
    </p>
  `);
}
 
export function receiptEmail(params: {
  plan: string;
  amount: string;
  invoiceUrl: string;
}): string {
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">
      Payment Received
    </h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Thanks for your payment. Here's your receipt:
    </p>
    <table style="width:100%;border-collapse:collapse;margin:16px 0;">
      <tr>
        <td style="padding:12px 0;border-bottom:1px solid #e5e7eb;color:#374151;">Plan</td>
        <td style="padding:12px 0;border-bottom:1px solid #e5e7eb;text-align:right;color:#374151;">
          ${escapeHtml(params.plan)}
        </td>
      </tr>
      <tr>
        <td style="padding:12px 0;font-weight:600;color:#111827;">Total</td>
        <td style="padding:12px 0;text-align:right;font-weight:600;color:#111827;">
          ${escapeHtml(params.amount)}
        </td>
      </tr>
    </table>
    <a href="${params.invoiceUrl}" style="color:#f97316;font-size:14px;">View full invoice</a>
  `);
}
 
export function teamInviteEmail(params: {
  inviterName: string;
  teamName: string;
  inviteUrl: string;
}): string {
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">
      You're Invited
    </h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      ${escapeHtml(params.inviterName)} invited you to join
      <strong>${escapeHtml(params.teamName)}</strong>.
    </p>
    ${button("Accept Invite", params.inviteUrl)}
    <p style="font-size:14px;color:#6b7280;margin-top:24px;">
      This invitation expires in 7 days.
    </p>
  `);
}

Use these templates in any Edge Function:

import { sendEmailOrThrow } from "../_shared/email.ts";
import { welcomeEmail } from "../_shared/templates.ts";
 
Deno.serve(async (req) => {
  const { name, email } = await req.json();
 
  const html = welcomeEmail(name, "https://app.yoursite.com/dashboard");
 
  await sendEmailOrThrow({
    to: email,
    subject: `Welcome, ${name}!`,
    body: html,
  });
 
  return Response.json({ sent: true });
});

Call Edge Functions from Your App

The Supabase client SDK provides a clean way to invoke Edge Functions from your frontend or backend:

import { createClient } from "@supabase/supabase-js";
 
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
 
// Invoke the function
const { data, error } = await supabase.functions.invoke("send-email", {
  body: {
    to: "user@example.com",
    subject: "Welcome!",
    body: "<h1>Welcome!</h1><p>Your account is ready.</p>",
  },
});
 
if (error) {
  console.error("Function invocation failed:", error.message);
}

With Auth Context

If the user is logged in, the Supabase client automatically passes the auth token to Edge Functions. You can verify it server-side:

// supabase/functions/send-invite/index.ts
import { createClient } from "jsr:@supabase/supabase-js@2";
 
Deno.serve(async (req) => {
  // Get the auth token from the request header
  const authHeader = req.headers.get("Authorization");
  if (!authHeader) {
    return Response.json({ error: "Missing auth header" }, { status: 401 });
  }
 
  // Create a Supabase client with the user's token
  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_ANON_KEY")!,
    { global: { headers: { Authorization: authHeader } } }
  );
 
  // Verify the user
  const { data: { user }, error: authError } = await supabase.auth.getUser();
  if (authError || !user) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }
 
  // Now send the email as the authenticated user
  const { inviteEmail } = await req.json();
 
  const response = await fetch("https://api.sequenzy.com/v1/transactional/send", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${Deno.env.get("SEQUENZY_API_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      to: inviteEmail,
      subject: `${user.email} invited you`,
      body: `<p>${user.email} invited you to join the team.</p>`,
    }),
  });
 
  return Response.json({ sent: response.ok });
});

CORS Handling

If you're calling Edge Functions from a browser, you need to handle CORS. Create a shared CORS helper:

// supabase/functions/_shared/cors.ts
export const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
  "Access-Control-Allow-Methods": "POST, OPTIONS",
};
 
export function handleCors(req: Request): Response | null {
  if (req.method === "OPTIONS") {
    return new Response("ok", { headers: corsHeaders });
  }
  return null;
}

Use it in your Edge Functions:

import { corsHeaders, handleCors } from "../_shared/cors.ts";
import { sendEmailOrThrow } from "../_shared/email.ts";
 
Deno.serve(async (req) => {
  // Handle CORS preflight
  const corsResponse = handleCors(req);
  if (corsResponse) return corsResponse;
 
  const { to, subject, body } = await req.json();
  await sendEmailOrThrow({ to, subject, body });
 
  return Response.json({ sent: true }, { headers: corsHeaders });
});

Database Webhook Triggers

One of Supabase's most powerful features is triggering Edge Functions when data changes. Instead of sending emails from your application code, you can fire them automatically when rows are inserted or updated.

Send Email on New Order

// supabase/functions/on-new-order/index.ts
import { sendEmailOrThrow } from "../_shared/email.ts";
import { receiptEmail } from "../_shared/templates.ts";
 
interface WebhookPayload {
  type: "INSERT" | "UPDATE" | "DELETE";
  table: string;
  schema: string;
  record: Record<string, unknown>;
  old_record: Record<string, unknown> | null;
}
 
Deno.serve(async (req) => {
  const payload: WebhookPayload = await req.json();
 
  // Only process inserts
  if (payload.type !== "INSERT") {
    return Response.json({ skipped: true });
  }
 
  const order = payload.record;
  const amount = `$${(Number(order.amount_cents) / 100).toFixed(2)}`;
 
  const html = receiptEmail({
    plan: order.plan_name as string,
    amount,
    invoiceUrl: `https://app.yoursite.com/invoices/${order.id}`,
  });
 
  await sendEmailOrThrow({
    to: order.customer_email as string,
    subject: `Order confirmed — ${amount}`,
    body: html,
  });
 
  return Response.json({ sent: true });
});

Set up the webhook in your Supabase dashboard: Database > Webhooks > Create webhook, select the orders table, trigger on INSERT, and point it to your Edge Function.

Or configure it with SQL:

-- Create the webhook trigger via SQL
select
  supabase_functions.http_request(
    'on-new-order',                          -- function name
    'https://YOUR_PROJECT_REF.supabase.co/functions/v1/on-new-order',
    '{"Content-Type":"application/json"}',
    '{}',
    '5000'                                   -- timeout ms
  );
 
create trigger on_new_order_trigger
  after insert on public.orders
  for each row
  execute function supabase_functions.http_request(
    'on-new-order',
    'https://YOUR_PROJECT_REF.supabase.co/functions/v1/on-new-order',
    '{"Content-Type":"application/json"}'
  );

Send Email on Status Change

Trigger emails when a row is updated — like notifying a user when their support ticket is resolved:

// supabase/functions/on-ticket-update/index.ts
import { sendEmailOrThrow } from "../_shared/email.ts";
 
interface WebhookPayload {
  type: "UPDATE";
  record: Record<string, unknown>;
  old_record: Record<string, unknown>;
}
 
Deno.serve(async (req) => {
  const payload: WebhookPayload = await req.json();
 
  const oldStatus = payload.old_record.status;
  const newStatus = payload.record.status;
 
  // Only send when status changes to "resolved"
  if (oldStatus === newStatus || newStatus !== "resolved") {
    return Response.json({ skipped: true });
  }
 
  await sendEmailOrThrow({
    to: payload.record.user_email as string,
    subject: `Ticket #${payload.record.id} resolved`,
    body: `
      <h2>Your ticket has been resolved</h2>
      <p><strong>Ticket:</strong> ${payload.record.title}</p>
      <p><strong>Resolution:</strong> ${payload.record.resolution ?? "Resolved by the team."}</p>
      <p>If you still need help, reply to this email or open a new ticket.</p>
    `,
  });
 
  return Response.json({ sent: true });
});

Auth Event Hooks

Supabase Auth fires webhooks when users sign up, sign in, or change their email. You can use these to send welcome emails, verification reminders, or security notifications.

Welcome Email on Signup

// supabase/functions/on-auth-signup/index.ts
import { sendEmailOrThrow } from "../_shared/email.ts";
import { welcomeEmail } from "../_shared/templates.ts";
 
interface AuthWebhookPayload {
  type: "INSERT";
  table: "users";
  schema: "auth";
  record: {
    id: string;
    email: string;
    raw_user_meta_data: {
      name?: string;
      full_name?: string;
      avatar_url?: string;
    };
    created_at: string;
  };
}
 
Deno.serve(async (req) => {
  const payload: AuthWebhookPayload = await req.json();
  const { email, raw_user_meta_data } = payload.record;
  const name = raw_user_meta_data?.name
    ?? raw_user_meta_data?.full_name
    ?? "there";
 
  const html = welcomeEmail(name, "https://app.yoursite.com/dashboard");
 
  await sendEmailOrThrow({
    to: email,
    subject: `Welcome, ${name}!`,
    body: html,
  });
 
  return Response.json({ sent: true });
});

Set it up: Go to Database > Webhooks, create a webhook on the auth.users table, trigger on INSERT.

Security Alert on New Sign-In

Send a notification when a user signs in from a new device. This requires a user_sessions table to track known devices:

// supabase/functions/on-new-signin/index.ts
import { createClient } from "jsr:@supabase/supabase-js@2";
import { sendEmailOrThrow } from "../_shared/email.ts";
 
Deno.serve(async (req) => {
  const { userId, ip, userAgent } = await req.json();
 
  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
  );
 
  // Check if this device/IP combo is known
  const { data: existing } = await supabase
    .from("user_sessions")
    .select("id")
    .eq("user_id", userId)
    .eq("ip_address", ip)
    .eq("user_agent", userAgent)
    .maybeSingle();
 
  if (existing) {
    return Response.json({ known_device: true });
  }
 
  // New device — record it and send alert
  await supabase.from("user_sessions").insert({
    user_id: userId,
    ip_address: ip,
    user_agent: userAgent,
  });
 
  // Get the user's email
  const { data: { user } } = await supabase.auth.admin.getUserById(userId);
  if (!user?.email) {
    return Response.json({ error: "User not found" }, { status: 404 });
  }
 
  await sendEmailOrThrow({
    to: user.email,
    subject: "New sign-in to your account",
    body: `
      <h2>New Sign-In Detected</h2>
      <p>We noticed a new sign-in to your account:</p>
      <table style="width:100%;border-collapse:collapse;margin:16px 0;">
        <tr>
          <td style="padding:8px;border-bottom:1px solid #e5e7eb;color:#6b7280;">IP Address</td>
          <td style="padding:8px;border-bottom:1px solid #e5e7eb;">${ip}</td>
        </tr>
        <tr>
          <td style="padding:8px;border-bottom:1px solid #e5e7eb;color:#6b7280;">Device</td>
          <td style="padding:8px;border-bottom:1px solid #e5e7eb;">${userAgent}</td>
        </tr>
        <tr>
          <td style="padding:8px;color:#6b7280;">Time</td>
          <td style="padding:8px;">${new Date().toUTCString()}</td>
        </tr>
      </table>
      <p style="color:#6b7280;font-size:14px;">
        If this wasn't you, reset your password immediately.
      </p>
    `,
  });
 
  return Response.json({ alert_sent: true });
});

Common SaaS Patterns

Password Reset

supabase/functions/password-reset/index.ts
import { passwordResetEmail } from "../_shared/templates.ts";

Deno.serve(async (req) => {
const { email, resetToken } = await req.json();
const resetUrl = `https://app.yoursite.com/reset-password?token=${resetToken}`;

const response = await fetch("https://api.sequenzy.com/v1/transactional/send", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${Deno.env.get("SEQUENZY_API_KEY")}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    to: email,
    subject: "Reset your password",
    body: passwordResetEmail(resetUrl),
  }),
});

return Response.json({ sent: response.ok });
});
supabase/functions/password-reset/index.ts
import { passwordResetEmail } from "../_shared/templates.ts";

Deno.serve(async (req) => {
const { email, resetToken } = await req.json();
const resetUrl = `https://app.yoursite.com/reset-password?token=${resetToken}`;

const response = await fetch("https://api.resend.com/emails", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${Deno.env.get("RESEND_API_KEY")}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: "Your App <noreply@yourdomain.com>",
    to: email,
    subject: "Reset your password",
    html: passwordResetEmail(resetUrl),
  }),
});

return Response.json({ sent: response.ok });
});
supabase/functions/password-reset/index.ts
import { passwordResetEmail } from "../_shared/templates.ts";

Deno.serve(async (req) => {
const { email, resetToken } = await req.json();
const resetUrl = `https://app.yoursite.com/reset-password?token=${resetToken}`;

const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${Deno.env.get("SENDGRID_API_KEY")}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    personalizations: [{ to: [{ email }] }],
    from: { email: "noreply@yourdomain.com" },
    subject: "Reset your password",
    content: [{ type: "text/html", value: passwordResetEmail(resetUrl) }],
  }),
});

return Response.json({ sent: response.ok });
});

Stripe Webhook Handler

Handle Stripe events in an Edge Function. Since Edge Functions use the Web Crypto API, you verify the webhook signature with crypto.subtle:

supabase/functions/stripe-webhook/index.ts
import { receiptEmail } from "../_shared/templates.ts";

async function verifyStripeSignature(
payload: string,
signature: string,
secret: string
): Promise<boolean> {
const parts = Object.fromEntries(
  signature.split(",").map((part) => {
    const [key, value] = part.split("=");
    return [key, value];
  })
);

const timestamp = parts["t"];
const expectedSig = parts["v1"];
if (!timestamp || !expectedSig) return false;

const signedPayload = `${timestamp}.${payload}`;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
  "raw",
  encoder.encode(secret),
  { name: "HMAC", hash: "SHA-256" },
  false,
  ["sign"]
);

const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(signedPayload));
const computed = Array.from(new Uint8Array(sig))
  .map((b) => b.toString(16).padStart(2, "0"))
  .join("");

return computed === expectedSig;
}

Deno.serve(async (req) => {
const body = await req.text();
const signature = req.headers.get("stripe-signature");

if (!signature) {
  return Response.json({ error: "Missing signature" }, { status: 400 });
}

const isValid = await verifyStripeSignature(
  body,
  signature,
  Deno.env.get("STRIPE_WEBHOOK_SECRET")!
);

if (!isValid) {
  return Response.json({ error: "Invalid signature" }, { status: 401 });
}

const event = JSON.parse(body);

if (event.type === "checkout.session.completed") {
  const session = event.data.object;
  const amount = `$${(session.amount_total / 100).toFixed(2)}`;

  const html = receiptEmail({
    plan: session.metadata?.plan ?? "Subscription",
    amount,
    invoiceUrl: session.metadata?.invoice_url ?? "#",
  });

  await fetch("https://api.sequenzy.com/v1/transactional/send", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${Deno.env.get("SEQUENZY_API_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      to: session.customer_email,
      subject: `Payment confirmed — ${amount}`,
      body: html,
    }),
  });

  // Also add them as a subscriber
  await fetch("https://api.sequenzy.com/v1/subscribers", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${Deno.env.get("SEQUENZY_API_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      email: session.customer_email,
      tags: ["customer", "stripe"],
      attributes: { plan: session.metadata?.plan },
    }),
  });
}

if (event.type === "invoice.payment_failed") {
  const invoice = event.data.object;

  await fetch("https://api.sequenzy.com/v1/transactional/send", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${Deno.env.get("SEQUENZY_API_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      to: invoice.customer_email,
      subject: "Payment failed — action required",
      body: `
        <h2>Payment Failed</h2>
        <p>We couldn't process your payment. Please update your payment method to keep your subscription active.</p>
        <a href="https://app.yoursite.com/billing"
           style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
          Update Payment Method
        </a>
      `,
    }),
  });
}

return Response.json({ received: true });
});
supabase/functions/stripe-webhook/index.ts
import { receiptEmail } from "../_shared/templates.ts";

async function verifyStripeSignature(
payload: string,
signature: string,
secret: string
): Promise<boolean> {
const parts = Object.fromEntries(
  signature.split(",").map((part) => {
    const [key, value] = part.split("=");
    return [key, value];
  })
);

const timestamp = parts["t"];
const expectedSig = parts["v1"];
if (!timestamp || !expectedSig) return false;

const signedPayload = `${timestamp}.${payload}`;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
  "raw",
  encoder.encode(secret),
  { name: "HMAC", hash: "SHA-256" },
  false,
  ["sign"]
);

const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(signedPayload));
const computed = Array.from(new Uint8Array(sig))
  .map((b) => b.toString(16).padStart(2, "0"))
  .join("");

return computed === expectedSig;
}

Deno.serve(async (req) => {
const body = await req.text();
const signature = req.headers.get("stripe-signature");

if (!signature) {
  return Response.json({ error: "Missing signature" }, { status: 400 });
}

const isValid = await verifyStripeSignature(
  body,
  signature,
  Deno.env.get("STRIPE_WEBHOOK_SECRET")!
);

if (!isValid) {
  return Response.json({ error: "Invalid signature" }, { status: 401 });
}

const event = JSON.parse(body);

if (event.type === "checkout.session.completed") {
  const session = event.data.object;
  const amount = `$${(session.amount_total / 100).toFixed(2)}`;

  const html = receiptEmail({
    plan: session.metadata?.plan ?? "Subscription",
    amount,
    invoiceUrl: session.metadata?.invoice_url ?? "#",
  });

  await fetch("https://api.resend.com/emails", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${Deno.env.get("RESEND_API_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      from: "Your App <billing@yourdomain.com>",
      to: session.customer_email,
      subject: `Payment confirmed — ${amount}`,
      html,
    }),
  });
}

if (event.type === "invoice.payment_failed") {
  const invoice = event.data.object;

  await fetch("https://api.resend.com/emails", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${Deno.env.get("RESEND_API_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      from: "Your App <billing@yourdomain.com>",
      to: invoice.customer_email,
      subject: "Payment failed — action required",
      html: `
        <h2>Payment Failed</h2>
        <p>We couldn't process your payment. Please update your payment method to keep your subscription active.</p>
        <a href="https://app.yoursite.com/billing"
           style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
          Update Payment Method
        </a>
      `,
    }),
  });
}

return Response.json({ received: true });
});
supabase/functions/stripe-webhook/index.ts
import { receiptEmail } from "../_shared/templates.ts";

async function verifyStripeSignature(
payload: string,
signature: string,
secret: string
): Promise<boolean> {
const parts = Object.fromEntries(
  signature.split(",").map((part) => {
    const [key, value] = part.split("=");
    return [key, value];
  })
);

const timestamp = parts["t"];
const expectedSig = parts["v1"];
if (!timestamp || !expectedSig) return false;

const signedPayload = `${timestamp}.${payload}`;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
  "raw",
  encoder.encode(secret),
  { name: "HMAC", hash: "SHA-256" },
  false,
  ["sign"]
);

const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(signedPayload));
const computed = Array.from(new Uint8Array(sig))
  .map((b) => b.toString(16).padStart(2, "0"))
  .join("");

return computed === expectedSig;
}

Deno.serve(async (req) => {
const body = await req.text();
const signature = req.headers.get("stripe-signature");

if (!signature) {
  return Response.json({ error: "Missing signature" }, { status: 400 });
}

const isValid = await verifyStripeSignature(
  body,
  signature,
  Deno.env.get("STRIPE_WEBHOOK_SECRET")!
);

if (!isValid) {
  return Response.json({ error: "Invalid signature" }, { status: 401 });
}

const event = JSON.parse(body);

if (event.type === "checkout.session.completed") {
  const session = event.data.object;
  const amount = `$${(session.amount_total / 100).toFixed(2)}`;

  const html = receiptEmail({
    plan: session.metadata?.plan ?? "Subscription",
    amount,
    invoiceUrl: session.metadata?.invoice_url ?? "#",
  });

  await fetch("https://api.sendgrid.com/v3/mail/send", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${Deno.env.get("SENDGRID_API_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      personalizations: [{ to: [{ email: session.customer_email }] }],
      from: { email: "billing@yourdomain.com" },
      subject: `Payment confirmed — ${amount}`,
      content: [{ type: "text/html", value: html }],
    }),
  });
}

if (event.type === "invoice.payment_failed") {
  const invoice = event.data.object;

  await fetch("https://api.sendgrid.com/v3/mail/send", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${Deno.env.get("SENDGRID_API_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      personalizations: [{ to: [{ email: invoice.customer_email }] }],
      from: { email: "billing@yourdomain.com" },
      subject: "Payment failed — action required",
      content: [{ type: "text/html", value: `
        <h2>Payment Failed</h2>
        <p>We couldn't process your payment. Please update your payment method to keep your subscription active.</p>
        <a href="https://app.yoursite.com/billing"
           style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
          Update Payment Method
        </a>
      ` }],
    }),
  });
}

return Response.json({ received: true });
});

Set your Stripe webhook secret:

supabase secrets set STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret

Database-Driven Email Queue

For high-volume or time-sensitive emails, you might want a queue rather than sending inline. Use a Postgres table as a queue and process it with pg_cron (available on all Supabase projects):

Create the Queue Table

create table public.email_queue (
  id uuid default gen_random_uuid() primary key,
  to_address text not null,
  subject text not null,
  html_body text not null,
  status text default 'pending' check (status in ('pending', 'processing', 'sent', 'failed')),
  attempts int default 0,
  max_attempts int default 3,
  error text,
  created_at timestamptz default now(),
  processed_at timestamptz,
  scheduled_for timestamptz default now()
);
 
-- Index for the queue processor
create index idx_email_queue_pending
  on public.email_queue (scheduled_for)
  where status = 'pending';

Queue an Email Instead of Sending Inline

// supabase/functions/_shared/queue.ts
import { createClient } from "jsr:@supabase/supabase-js@2";
 
const supabase = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
 
export async function queueEmail(params: {
  to: string;
  subject: string;
  html: string;
  scheduledFor?: Date;
}) {
  const { error } = await supabase.from("email_queue").insert({
    to_address: params.to,
    subject: params.subject,
    html_body: params.html,
    scheduled_for: params.scheduledFor?.toISOString() ?? new Date().toISOString(),
  });
 
  if (error) throw new Error(`Failed to queue email: ${error.message}`);
}
 
// Queue multiple emails at once
export async function queueBatch(
  emails: Array<{ to: string; subject: string; html: string }>
) {
  const { error } = await supabase.from("email_queue").insert(
    emails.map((e) => ({
      to_address: e.to,
      subject: e.subject,
      html_body: e.html,
    }))
  );
 
  if (error) throw new Error(`Failed to queue batch: ${error.message}`);
}

Process the Queue

Create an Edge Function that processes pending emails:

// supabase/functions/process-email-queue/index.ts
import { createClient } from "jsr:@supabase/supabase-js@2";
import { sendEmail } from "../_shared/email.ts";
 
const BATCH_SIZE = 10;
 
Deno.serve(async (req) => {
  // Verify this is called by pg_cron or an admin, not a random user
  const authHeader = req.headers.get("Authorization");
  const expectedKey = Deno.env.get("CRON_SECRET");
  if (expectedKey && authHeader !== `Bearer ${expectedKey}`) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }
 
  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
  );
 
  // Fetch pending emails that are due
  const { data: emails, error } = await supabase
    .from("email_queue")
    .select("*")
    .eq("status", "pending")
    .lte("scheduled_for", new Date().toISOString())
    .lt("attempts", 3)
    .order("created_at", { ascending: true })
    .limit(BATCH_SIZE);
 
  if (error || !emails?.length) {
    return Response.json({ processed: 0 });
  }
 
  let sent = 0;
  let failed = 0;
 
  for (const email of emails) {
    // Mark as processing
    await supabase
      .from("email_queue")
      .update({ status: "processing", attempts: email.attempts + 1 })
      .eq("id", email.id);
 
    const result = await sendEmail({
      to: email.to_address,
      subject: email.subject,
      body: email.html_body,
    });
 
    if (result.success) {
      await supabase
        .from("email_queue")
        .update({ status: "sent", processed_at: new Date().toISOString() })
        .eq("id", email.id);
      sent++;
    } else {
      const newStatus = email.attempts + 1 >= email.max_attempts ? "failed" : "pending";
      await supabase
        .from("email_queue")
        .update({ status: newStatus, error: result.error })
        .eq("id", email.id);
      failed++;
    }
  }
 
  return Response.json({ processed: emails.length, sent, failed });
});

Schedule with pg_cron

Enable pg_cron in your Supabase dashboard (Database > Extensions), then schedule the queue processor:

-- Process the email queue every minute
select cron.schedule(
  'process-email-queue',
  '* * * * *',
  $$
  select net.http_post(
    url := 'https://YOUR_PROJECT_REF.supabase.co/functions/v1/process-email-queue',
    headers := jsonb_build_object(
      'Authorization', 'Bearer ' || current_setting('app.settings.cron_secret'),
      'Content-Type', 'application/json'
    ),
    body := '{}'::jsonb
  );
  $$
);

Error Handling

Edge Functions have a 150-second execution limit (wall clock time). Email API calls are fast, but you should still handle errors properly.

Retry with Backoff

// supabase/functions/_shared/retry.ts
export async function withRetry<T>(
  fn: () => Promise<T>,
  options: { maxRetries?: number; baseDelay?: number } = {}
): Promise<T> {
  const { maxRetries = 3, baseDelay = 1000 } = options;
 
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries) throw error;
 
      const delay = baseDelay * Math.pow(2, attempt);
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
 
  throw new Error("Unreachable");
}

Structured Error Responses

// supabase/functions/_shared/errors.ts
export class EmailError extends Error {
  constructor(
    message: string,
    public statusCode: number,
    public retryable: boolean
  ) {
    super(message);
    this.name = "EmailError";
  }
}
 
export function errorResponse(error: unknown): Response {
  if (error instanceof EmailError) {
    return Response.json(
      { error: error.message, retryable: error.retryable },
      { status: error.statusCode }
    );
  }
 
  console.error("Unexpected error:", error);
  return Response.json(
    { error: "Internal server error" },
    { status: 500 }
  );
}

Using It All Together

// supabase/functions/send-welcome/index.ts
import { corsHeaders, handleCors } from "../_shared/cors.ts";
import { sendEmailOrThrow } from "../_shared/email.ts";
import { welcomeEmail } from "../_shared/templates.ts";
import { withRetry } from "../_shared/retry.ts";
import { EmailError, errorResponse } from "../_shared/errors.ts";
 
Deno.serve(async (req) => {
  const corsResponse = handleCors(req);
  if (corsResponse) return corsResponse;
 
  try {
    const { name, email } = await req.json();
 
    if (!name || !email) {
      throw new EmailError("name and email are required", 400, false);
    }
 
    const html = welcomeEmail(name, "https://app.yoursite.com/dashboard");
 
    await withRetry(() =>
      sendEmailOrThrow({
        to: email,
        subject: `Welcome, ${name}!`,
        body: html,
      })
    );
 
    return Response.json({ sent: true }, { headers: corsHeaders });
  } catch (error) {
    return errorResponse(error);
  }
});

Testing Edge Functions Locally

Supabase provides a local development environment that mirrors production:

# Start the local Supabase stack (database, auth, storage, Edge Functions)
supabase start
 
# Serve a specific function with hot reloading
supabase functions serve send-email --env-file supabase/.env.local
 
# Serve all functions
supabase functions serve --env-file supabase/.env.local

Test with curl:

# Test the send-email function
curl -X POST http://localhost:54321/functions/v1/send-email \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
  -H "Content-Type: application/json" \
  -d '{"to": "test@example.com", "subject": "Test", "body": "<p>Hello</p>"}'
 
# Test a webhook trigger (simulate database payload)
curl -X POST http://localhost:54321/functions/v1/on-new-order \
  -H "Content-Type: application/json" \
  -d '{
    "type": "INSERT",
    "table": "orders",
    "schema": "public",
    "record": {
      "id": "ord_123",
      "customer_email": "test@example.com",
      "amount_cents": 4900,
      "plan_name": "Pro"
    },
    "old_record": null
  }'

Write Deno Tests

Create test files alongside your functions:

// supabase/functions/tests/send-email.test.ts
import { assertEquals } from "https://deno.land/std/assert/mod.ts";
 
// Mock the email API
const originalFetch = globalThis.fetch;
 
Deno.test("send-email returns 405 for GET requests", async () => {
  const handler = (await import("../send-email/index.ts")).default;
 
  // The function uses Deno.serve, so we test the handler directly
  const req = new Request("http://localhost/send-email", { method: "GET" });
  const res = await handler(req);
 
  assertEquals(res.status, 405);
});
 
Deno.test("send-email sends email successfully", async () => {
  // Mock fetch to intercept the email API call
  globalThis.fetch = async (input: string | URL | Request) => {
    const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
 
    if (url.includes("sequenzy.com") || url.includes("resend.com") || url.includes("sendgrid.com")) {
      return new Response(JSON.stringify({ jobId: "test-123" }), {
        status: 200,
        headers: { "Content-Type": "application/json" },
      });
    }
 
    return originalFetch(input);
  };
 
  try {
    const req = new Request("http://localhost/send-email", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        to: "user@example.com",
        subject: "Test",
        body: "<p>Hello</p>",
      }),
    });
 
    // Import and call the handler
    // Note: This is simplified — in practice, you'd extract the handler
    // from Deno.serve or use a testing framework
  } finally {
    globalThis.fetch = originalFetch;
  }
});

Run tests:

deno test supabase/functions/tests/ --allow-env --allow-net

Going to Production

1. Verify Your Domain

Add SPF, DKIM, and DMARC DNS records through your email provider's dashboard. Without this, your emails go straight to spam. No exceptions.

2. Use a Dedicated Sending Domain

Send from mail.yourapp.com instead of your root domain. If your email reputation takes a hit, your main domain stays clean.

3. Secure Your Edge Functions

Not every Edge Function should be publicly callable. For webhook handlers and queue processors, verify the caller:

// For Stripe webhooks: verify the signature (shown above)
// For pg_cron: use a shared secret
// For internal functions: use the service role key
 
function verifyInternalCall(req: Request): boolean {
  const authHeader = req.headers.get("Authorization");
  const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
  return authHeader === `Bearer ${serviceRoleKey}`;
}

4. Set All Secrets

# Email provider
supabase secrets set SEQUENZY_API_KEY=sq_your_key
 
# Stripe (if using webhooks)
supabase secrets set STRIPE_WEBHOOK_SECRET=whsec_your_secret
 
# Queue processor secret
supabase secrets set CRON_SECRET=your_random_secret
 
# Verify they're set
supabase secrets list

5. Monitor Edge Function Logs

# Tail logs in real-time
supabase functions logs send-email --tail
 
# View recent logs
supabase functions logs send-email

6. Rate Limiting

Edge Functions don't have built-in rate limiting. For user-facing functions, add a simple check using Supabase's database:

import { createClient } from "jsr:@supabase/supabase-js@2";
 
const supabase = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
 
async function checkRateLimit(email: string, maxPerHour = 5): Promise<boolean> {
  const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
 
  const { count } = await supabase
    .from("email_queue")
    .select("*", { count: "exact", head: true })
    .eq("to_address", email)
    .gte("created_at", oneHourAgo);
 
  return (count ?? 0) < maxPerHour;
}

Production Checklist

StepWhatWhy
Domain verificationSPF, DKIM, DMARC recordsEmails land in inbox, not spam
Dedicated sending domainmail.yourapp.comProtects your root domain reputation
Secrets configuredAll API keys in supabase secretsNever hardcode credentials
Error handlingRetry logic + error responsesDon't lose emails to transient failures
Rate limitingPer-recipient limitsPrevent abuse from bots or bugs
Monitoringsupabase functions logsCatch issues before users report them
Webhook verificationStripe signature checkPrevent spoofed webhook calls
CORS configurationRestrict allowed originsDon't let any domain call your functions

Beyond Transactional: Marketing Emails and Sequences

At some point, you'll want more than one-off emails. You'll want to send welcome emails and:

  • Send onboarding sequences that guide new users through your product over several days
  • Run marketing campaigns to announce features or share updates
  • Automate lifecycle emails based on what users do in your app
  • Track engagement to see which emails get opened and clicked

Most teams wire together a transactional provider with a separate marketing tool (Mailchimp, ConvertKit). That means two dashboards, two billing systems, and keeping subscriber lists in sync.

Sequenzy handles both from one platform. Same API, same dashboard. Transactional sends, marketing campaigns, automated sequences, subscriber segments, and native Stripe integration for SaaS-specific automations.

Here's what subscriber management looks like from an Edge Function:

// supabase/functions/_shared/subscribers.ts
const SEQUENZY_API = "https://api.sequenzy.com/v1";
 
export async function addSubscriber(params: {
  email: string;
  name?: string;
  tags?: string[];
  attributes?: Record<string, unknown>;
}) {
  const response = await fetch(`${SEQUENZY_API}/subscribers`, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${Deno.env.get("SEQUENZY_API_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      email: params.email,
      firstName: params.name,
      tags: params.tags,
      customAttributes: params.attributes,
    }),
  });
 
  return response.json();
}
 
export async function addTag(email: string, tag: string) {
  const response = await fetch(`${SEQUENZY_API}/subscribers/tags/add`, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${Deno.env.get("SEQUENZY_API_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ email, tag }),
  });
 
  return response.json();
}
 
export async function trackEvent(
  email: string,
  event: string,
  properties?: Record<string, unknown>
) {
  const response = await fetch(`${SEQUENZY_API}/subscribers/events/trigger`, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${Deno.env.get("SEQUENZY_API_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ email, event, properties }),
  });
 
  return response.json();
}

Use it in your auth webhook:

// supabase/functions/on-auth-signup/index.ts
import { sendEmailOrThrow } from "../_shared/email.ts";
import { welcomeEmail } from "../_shared/templates.ts";
import { addSubscriber } from "../_shared/subscribers.ts";
 
Deno.serve(async (req) => {
  const payload = await req.json();
  const { email, raw_user_meta_data } = payload.record;
  const name = raw_user_meta_data?.name ?? "there";
 
  // Send welcome email
  const html = welcomeEmail(name, "https://app.yoursite.com/dashboard");
  await sendEmailOrThrow({
    to: email,
    subject: `Welcome, ${name}!`,
    body: html,
  });
 
  // Add to Sequenzy for marketing sequences
  await addSubscriber({
    email,
    name,
    tags: ["signed-up"],
    attributes: {
      source: raw_user_meta_data?.source ?? "direct",
      signupDate: new Date().toISOString(),
    },
  });
 
  return Response.json({ sent: true });
});

FAQ

Can I use Supabase's built-in email for transactional emails?

No. Supabase's built-in email (via GoTrue/Auth) is only for authentication flows — sign-up confirmation, password reset, and magic links. It uses a shared SMTP service with strict rate limits (3-4 emails per hour in development). For transactional emails like receipts, welcome messages, or notifications, you need an Edge Function with a dedicated email provider.

Do Edge Functions support npm packages?

Yes, but not via node_modules. Supabase Edge Functions run Deno, so you import packages from npm: specifiers (e.g., import Stripe from "npm:stripe@17") or from JSR (jsr:@supabase/supabase-js@2). For email providers, the HTTP API approach shown in this guide is recommended since it avoids SDK compatibility issues with Deno.

What's the execution time limit for Edge Functions?

Edge Functions have a 150-second wall clock time limit. Individual email API calls typically complete in under 1 second, so this is rarely an issue. If you're sending many emails in a single function invocation, use the database queue pattern instead.

How do I send emails from a database trigger without an Edge Function?

You can use the pg_net extension (available on all Supabase projects) to make HTTP calls directly from SQL triggers:

create or replace function notify_on_insert()
returns trigger as $$
begin
  perform net.http_post(
    url := 'https://api.sequenzy.com/v1/transactional/send',
    headers := jsonb_build_object(
      'Authorization', 'Bearer ' || current_setting('app.settings.sequenzy_api_key'),
      'Content-Type', 'application/json'
    ),
    body := jsonb_build_object(
      'to', NEW.email,
      'subject', 'Order confirmed',
      'body', '<p>Your order #' || NEW.id || ' is confirmed.</p>'
    )
  );
  return NEW;
end;
$$ language plpgsql;

This works for simple cases but Edge Functions give you more control over error handling and template rendering.

How do I handle CORS for Edge Functions called from the browser?

Use the shared CORS helper shown in this guide. Add the corsHeaders to every response and handle OPTIONS preflight requests. For production, replace Access-Control-Allow-Origin: * with your specific domain.

Can I schedule emails to send later?

Yes, two approaches: (1) Use the database queue with a scheduled_for column and pg_cron, as shown in the "Database-Driven Email Queue" section. (2) Some email providers like Sequenzy support scheduled sends natively via their API.

What happens if my Edge Function throws an error during a webhook?

Supabase will retry the webhook call up to 3 times with exponential backoff. If all retries fail, the event is logged in the Supabase dashboard under Database > Webhooks > Logs. For critical emails, the database queue pattern is more reliable since failed emails stay in the queue and get retried independently.

How do I test database webhook triggers locally?

Start your local Supabase stack with supabase start, then serve your Edge Functions. You can simulate webhook payloads with curl (as shown in the testing section) or insert rows into your local database and configure local webhooks via the Supabase dashboard at localhost:54323.

Should I use the Supabase client inside Edge Functions?

Yes, when you need to query or update your database from within an Edge Function. Use the service role key (not the anon key) for server-side operations that bypass RLS. The SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are automatically available as environment variables in deployed Edge Functions.

How do I send bulk emails (like a newsletter) from Supabase?

Don't send bulk emails directly from Edge Functions — they have execution time limits and aren't designed for batch processing. Instead, use a dedicated email marketing platform like Sequenzy that handles bulk sends, deliverability, unsubscribe management, and compliance. Your Edge Function just needs to manage subscriber data; the marketing platform handles the actual sending.

Wrapping Up

Here's what we covered:

  1. Edge Functions as the foundation for server-side email sending in Supabase
  2. Shared modules in _shared/ to avoid duplicating email logic across functions
  3. HTML templates with proper escaping and consistent layouts
  4. Database webhooks to trigger emails automatically on data changes
  5. Auth hooks for welcome emails and security notifications
  6. Stripe webhooks with Web Crypto API signature verification
  7. Database queues with pg_cron for reliable, high-volume email processing
  8. Error handling with retries, structured errors, and CORS
  9. Production checklist: domain verification, secrets management, monitoring

The patterns in this guide scale from a side project to a production SaaS. Start with a single Edge Function, add shared modules as you grow, and switch to a database queue when you need guaranteed delivery.

Frequently Asked Questions

Can I send emails from Supabase Edge Functions?

Yes. Edge Functions run on Deno and support outbound fetch requests. Import your email SDK or make direct HTTP calls to your email provider's API. Edge Functions are ideal for webhook-triggered and event-driven email sends.

How do I store email API keys in Supabase?

Use Supabase Secrets: supabase secrets set SEQUENZY_API_KEY=your-key. Access them in Edge Functions with Deno.env.get('SEQUENZY_API_KEY'). Never hardcode keys in your function code since it's deployed as source.

Can I trigger emails from Supabase database changes?

Yes. Use Database Webhooks (pg_net) or Database Functions with pg_notify to trigger Edge Functions when rows are inserted or updated. For example, trigger a welcome email when a new row is added to the profiles table.

How do I send emails from Supabase Auth hooks?

Use Supabase Auth hooks to trigger Edge Functions on events like signup, login, and password reset. Configure the hook URL in your Supabase dashboard to point to your Edge Function. The function receives the user's details in the request body.

What's the execution time limit for Supabase Edge Functions?

Edge Functions have a 150-second wall-time limit. Single email sends take well under a second. For bulk operations, process in batches within the time limit or use a Supabase CRON job (pg_cron) to trigger batched sends over multiple invocations.

How do I test Supabase Edge Functions locally?

Use supabase functions serve to run functions locally. Set environment variables in .env.local. Call your function with curl or from your app. The local environment matches production behavior for most use cases.

Can I use Supabase's built-in email for transactional emails?

Supabase includes basic email for Auth flows (confirmation, password reset) via their email provider. For custom transactional emails, marketing campaigns, or branded templates, use a dedicated email provider called from Edge Functions.

How do I handle email sending errors in Edge Functions?

Wrap your email SDK call in a try/catch block. Return appropriate HTTP status codes (500 for failures, 429 for rate limits). Log errors with console.error() which appears in the Supabase Functions logs dashboard.

Can I use Supabase Realtime to trigger emails?

Not directly. Realtime is for client-side subscriptions, not server-side triggers. Use Database Webhooks or pg_notify to invoke Edge Functions from database changes. These run server-side and can safely access email API keys.

How do I send emails with data from Supabase tables?

In your Edge Function, create a Supabase client with the service role key to query your database. Fetch the relevant data (user details, order information), then pass it to your email template. Use the service role key only in Edge Functions, never in client code.