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.

2 Likes

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"
  }
}'
2 Likes

Thank your for sharing your findings!

I felt more confident using the message queue due to potential volume and the ability to schedule specific types. I also allow admins to send messages to a group of people at once, and I split the recipient list in the workflow.

However, the direct API approach is much easier to implement! Those just getting started might prefer this approach as there is lower latency.

Ditch Sendgrid and move to Resend and thank me later :wink:

Thank you for the feedback. Are you suggesting more emails reach humans as their tagline states?

I initially used Resend for email authentication with Supabase, but I chose SendGrid because of its templates.

With resend you cal also use new.email - which is another product from Resend with tight integration - for templates and AI prompted designs - https://new.email

3 Likes

Okay, just a quick update: I have started moving everything to Resend and am using Supabase Edge Functions, especially now that I can deploy Edge functions through their UI.

I used Claude :sparkles: to recreate the email template from a screenshot. The Edge function not only merges the data into the template and sends the email via Resend, but it also updates my email queue with a ‘sent’ or ‘failed’ status.

I’ll transform my first trigger and add two other templates and triggers this weekend, but my first test is working well with my queue. In a nutshell, the Clade AI code generation is so good that it’s easy for me to move this to an Edge function now.

:folded_hands: Thanks for the inspiration!

This means I can cancel my Elestio instance and SendGrid, both of which I am currently paying for with a monthly subscription. :money_bag:

4 Likes

Trigger update! :grinning_face:

Setting up a trigger to react to a table event is super easy! Supabase provides Webhooks that allow you to easily select the table, the table event, and the Edge function to execute.

I created a webhook to monitor inserts and updates to the mail_queue table. The Edge function selects items where the email_status is “pending,” sends the email, and then updates the mail_status.

1 Like

Resend is just sooooo good. Anyone still on Sendgrid is REALLY missing out.

1 Like

Resend makes a big difference, doesn’t it? I’m enjoying the overall experience and improvements since I last reviewed them.

Here’s the final update on triggers. Due to the nature of Webhooks, I encountered race conditions when triggering and processing a batch of emails. I’m sure there is a programmatic solution, but creating Supabase Cron jobs is very easy.

Supabase now provides a user-friendly interface for Cron and Webhooks.

1 Like