Guide: Sending mails using Sendgrid + Supabase?

Is there a clear guide on a ‘standard’ approach to sending emails from Sendgrid?

Seems the recommended way is to have edgefunctions on sendgrid to do this.

Thoughts?

Use cases are mostly for signups, confirmation of actions like purchases, etc.

Thanks,
Toby

Hi, just a backend function.

in weweb or supabase?

Supabase

Hi Toby, indeed using backend functions is the best approach.

After some research, I decided to use SendGrid and invoke it through an instance of n8n.

On a schedule, n8n checks my Supabase email_queue table to see if any emails need to be sent. Then, n8n calls SendGrid, and updates the status of the email_queue row and sets a sent_at column. I use n8n because it supports SendGrid templates, which are great for specific emails.

One other tip. I’m not sure if you’ve sent emails from a backend, but be very cautious about how you test. There is no undo to email and you don’t want to ruin your domain’s email reputation score.

It may be worthwhile to set up a few test domains and add email with a catch-all email, so you can send quick test emails to blah@tobytest.net, hello@tobytest.net, etc. but receive it in one inbox.

There are other workflows that can do the same things, but n8n supports the SendGrid templates with were important to me.

1 Like

I used up using an edge function in Supabase which is fine with GPT’s help - make sure you have the sendgrid key in the variables. If you’re having trouble setting this up in the UI, delete and re-try or use the CLI.

import { serve } from "https://deno.land/std@0.177.0/http/server.ts";

console.log("Edge Function 'send-email' is running...");

const SENDGRID_API_KEY = Deno.env.get('SENDGRID_API_KEY');
const FROM_EMAIL = "no-reply@domain.com"; // Must be verified in SendGrid

function corsHeaders() {
  return {
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type, Authorization"
  };
}

serve(async (req) => {
  if (req.method === "OPTIONS") {
    return new Response("ok", { headers: corsHeaders() });
  }

  try {
    const { to, subject, text, html, template_id, dynamic_template_data } = await req.json();
    console.log("Received payload:", { to, subject, text, html, template_id, dynamic_template_data });

    if (!to) {
      return new Response(JSON.stringify({
        success: false,
        error: "'to' email is required"
      }), { status: 400, headers: corsHeaders() });
    }

    const payload: Record<string, any> = {
      personalizations: [
        {
          to: [{ email: to }],
          ...(template_id && dynamic_template_data
            ? { dynamic_template_data }
            : subject ? { subject } : {})
        }
      ],
      from: {
        email: FROM_EMAIL,
        name: "Project Name"
      }
    };

    if (template_id) {
      payload.template_id = template_id;
    } else {
      // Fallback to manual content if no template used
      if (!subject || (!text && !html)) {
        return new Response(JSON.stringify({
          success: false,
          error: "Missing 'subject' and 'text' or 'html' for non-template email"
        }), { status: 400, headers: corsHeaders() });
      }

      payload.content = [
        {
          type: "text/plain",
          value: text || "Email content"
        },
        ...(html ? [{
          type: "text/html",
          value: html
        }] : [])
      ];
    }

    const sendgridRes = await fetch("https://api.sendgrid.com/v3/mail/send", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${SENDGRID_API_KEY}`,
        "Content-Type": "application/json"
      },
      body: JSON.stringify(payload)
    });

    if (!sendgridRes.ok) {
      const errorBody = await sendgridRes.text();
      console.error("SendGrid error:", errorBody);
      return new Response(JSON.stringify({
        success: false,
        error: errorBody
      }), {
        status: sendgridRes.status,
        headers: corsHeaders()
      });
    }

    return new Response(JSON.stringify({
      success: true,
      message: "Email sent"
    }), {
      status: 200,
      headers: corsHeaders()
    });

  } catch (err) {
    console.error("Unexpected error:", err);
    return new Response(JSON.stringify({
      success: false,
      error: err.message
    }), {
      status: 500,
      headers: corsHeaders()
    });
  }
});

and then then you call this from WeWeb, you’re using doing a call with the following - this will call whatever dynamic template you want to use and parse data for that template.

curl -L -X POST 'https://sdfsdfsd.supabase.co/functions/v1/send-email' \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhY32rdfsdfsdf' \
  -H 'Content-Type: application/json' \
  --data '{
  "to": "user@example.com",
  "template_id": "d-1234567890abcdef1234567890abcdef",
  "dynamic_template_data": {
    "first_name": "John",
    "data1": "XR Software Engineer",
    "cta_url": "https://domain.com"
  }
}'