Build a drip email automation system in Lovable with contacts, campaigns, and message templates stored in Supabase. An Edge Function sends emails via Resend on demand. A pg_cron job runs every hour to find contacts due for the next sequence step and triggers the send function — fully automated without any external queue service.
What you're building
A drip campaign is a sequence of emails sent automatically over time after a trigger event (e.g. someone subscribes). Each campaign has a steps array in its JSONB config: [{day: 0, template_id: 'welcome'}, {day: 3, template_id: 'follow-up'}, {day: 7, template_id: 'offer'}]. When a contact is enrolled in a campaign, a contact_enrollments row is created with enrolled_at set to now().
The pg_cron job runs hourly. It queries contact_enrollments WHERE the next step's send_at time (enrolled_at + day * interval '1 day') <= now() AND the step hasn't been sent yet. For each due contact-step pair, it calls the send-email Edge Function. This approach requires no external queue — Supabase itself is the scheduler.
Email templates use simple {{variable}} syntax. The Edge Function receives the template body and a variables object, replaces all {{key}} placeholders with the contact's data (first_name, company, etc.), then calls the Resend API to send the rendered email.
Send logs record every attempt: contact_id, campaign_id, template_id, sent_at, status (sent, failed, bounced), resend_message_id, and opened_at. Open tracking uses Resend's built-in tracking pixel. The campaign dashboard queries send_logs aggregated by day to show delivery volume and engagement trends in Recharts.
Final result
A fully automated drip email system that sends the right email to each contact at the right time, with delivery tracking and a campaign performance dashboard.
Tech stack
Prerequisites
- Resend account with a verified sending domain and API key (free tier sends 3,000 emails/month)
- RESEND_API_KEY saved in Cloud tab → Secrets
- pg_cron enabled in your Supabase project (Dashboard → Database → Extensions → pg_cron)
- Supabase Auth for the admin dashboard login
- Basic understanding of email deliverability concepts (SPF, DKIM) for production use
Build steps
Create the email automation schema
Build the complete database schema for contacts, campaigns, templates, enrollments, and send logs in one prompt. Getting the schema right before building the UI saves significant rework.
1Create a complete email automation schema in Supabase.23Tables:451. contacts:6 id uuid primary key default gen_random_uuid()7 email text not null unique8 first_name text9 last_name text10 company text11 tags text[] default '{}'12 status text default 'subscribed' — 'subscribed', 'unsubscribed', 'bounced', 'complained'13 source text14 metadata jsonb default '{}'15 subscribed_at timestamptz default now()16 unsubscribed_at timestamptz17 created_at timestamptz default now()18192. email_templates:20 id uuid primary key default gen_random_uuid()21 name text not null22 subject text not null23 html_body text not null — supports {{variable}} substitution24 text_body text25 preview_text text26 from_name text default 'Your Company'27 from_email text default 'hello@yourcompany.com'28 created_at timestamptz default now()29 updated_at timestamptz default now()30313. campaigns:32 id uuid primary key default gen_random_uuid()33 name text not null34 description text35 status text default 'draft' — 'draft', 'active', 'paused', 'archived'36 trigger_type text default 'manual' — 'manual', 'signup', 'tag'37 trigger_config jsonb default '{}' — for tag triggers: { tag: 'lead' }38 steps jsonb not null default '[]' — array of { day: int, template_id: uuid, subject_override?: string }39 created_at timestamptz default now()40 updated_at timestamptz default now()41424. contact_enrollments:43 id uuid primary key default gen_random_uuid()44 contact_id uuid references contacts(id) on delete cascade45 campaign_id uuid references campaigns(id) on delete cascade46 unique(contact_id, campaign_id)47 enrolled_at timestamptz default now()48 current_step int default 049 completed_at timestamptz50 unenrolled_at timestamptz51 status text default 'active' — 'active', 'completed', 'unenrolled'52535. send_logs:54 id uuid primary key default gen_random_uuid()55 contact_id uuid references contacts(id)56 campaign_id uuid references campaigns(id)57 template_id uuid references email_templates(id)58 enrollment_id uuid references contact_enrollments(id)59 step_index int60 to_email text not null61 subject text not null62 status text default 'pending' — 'pending', 'sent', 'failed', 'bounced', 'complained'63 resend_message_id text64 error_message text65 opened_at timestamptz66 clicked_at timestamptz67 sent_at timestamptz default now()6869RLS:70- All tables: authenticated users can read all rows71- contacts: authenticated INSERT/UPDATE/DELETE72- email_templates: authenticated INSERT/UPDATE/DELETE73- campaigns: authenticated INSERT/UPDATE/DELETE74- contact_enrollments: authenticated INSERT/UPDATE/DELETE75- send_logs: authenticated INSERT; no DELETE (audit trail)7677Create indexes:78- contacts(email), contacts(status), contacts(tags using GIN)79- contact_enrollments(contact_id, campaign_id), contact_enrollments(status)80- send_logs(contact_id), send_logs(campaign_id), send_logs(sent_at)Pro tip: Add a unique constraint on send_logs(enrollment_id, step_index) to prevent duplicate sends if the pg_cron job runs while a previous send is still in progress. The INSERT will fail gracefully instead of sending the same email twice.
Expected result: All five tables created with indexes and RLS. TypeScript types generated. The app shell renders in the preview.
Build the email send Edge Function
Create the core Edge Function that renders a template with contact variables and sends it via Resend. This function is called both manually and by the pg_cron scheduler.
1Create a Supabase Edge Function at supabase/functions/send-email/index.ts.23The function accepts POST requests with body:4{ enrollment_id: string, step_index: number }56Logic:71. Fetch the enrollment row with contact and campaign JOINed82. Get the step from campaign.steps[step_index]93. Fetch the email template by template_id104. Render the template: replace all {{variable}} occurrences with contact fields11 - Available variables: {{first_name}}, {{last_name}}, {{email}}, {{company}}, plus any contact.metadata keys12 - Use a simple regex replace: template.html_body.replace(/\{\{(\w+)\}\}/g, (_, key) => contactVars[key] ?? '')135. Create a send_logs row with status = 'pending'146. Call the Resend API:15 POST https://api.resend.com/emails16 Headers: Authorization: Bearer RESEND_API_KEY17 Body: { from: template.from_email, to: contact.email, subject, html: renderedHtml, text: renderedText }187. On success: update send_logs.status = 'sent', resend_message_id = response.id, sent_at = now()198. Update contact_enrollments.current_step = step_index + 1209. Check if this was the last step: if step_index + 1 >= campaign.steps.length, set enrollment.status = 'completed'2110. On failure: update send_logs.status = 'failed', error_message = error.message2211. Return { success: true, message_id } or { success: false, error }2324Always return 200 even on send failures — the pg_cron job should not retry individual sends.Pro tip: Add an unsubscribe link to every email automatically in this function. Append a line to the HTML body: '<p><a href="APP_URL/unsubscribe?contact_id=CONTACT_ID">Unsubscribe</a></p>'. Create an /unsubscribe route in your app that sets contacts.status = 'unsubscribed'.
Expected result: The Edge Function deploys. Calling it with a valid enrollment_id and step_index sends an email via Resend and creates a send_logs row with status='sent'.
Set up pg_cron for automated sequence processing
Configure a PostgreSQL cron job that runs hourly, finds all due enrollment steps, and triggers the send Edge Function for each one. This is the automation engine.
1// SQL to run in Supabase Dashboard → SQL Editor2// Step 1: Enable pg_cron extension (if not already enabled)3-- Do this in Dashboard → Database → Extensions → enable pg_cron45-- Step 2: Create a function that finds due steps and calls the Edge Function6CREATE OR REPLACE FUNCTION process_due_email_steps()7RETURNS void8LANGUAGE plpgsql9SECURITY DEFINER10AS $$11DECLARE12 enrollment RECORD;13 step_day integer;14 step_template_id uuid;15 send_at timestamptz;16BEGIN17 FOR enrollment IN18 SELECT19 ce.id AS enrollment_id,20 ce.contact_id,21 ce.campaign_id,22 ce.enrolled_at,23 ce.current_step,24 c.steps25 FROM contact_enrollments ce26 JOIN campaigns c ON c.id = ce.campaign_id27 WHERE ce.status = 'active'28 AND c.status = 'active'29 AND ce.current_step < jsonb_array_length(c.steps)30 LOOP31 step_day := (enrollment.steps -> enrollment.current_step ->> 'day')::integer;32 send_at := enrollment.enrolled_at + (step_day || ' days')::interval;3334 IF send_at <= now() THEN35 -- Check not already sent (unique constraint on enrollment_id, step_index prevents duplicates)36 IF NOT EXISTS (37 SELECT 1 FROM send_logs38 WHERE enrollment_id = enrollment.enrollment_id39 AND step_index = enrollment.current_step40 AND status IN ('sent', 'pending')41 ) THEN42 PERFORM net.http_post(43 url := current_setting('app.edge_function_url') || '/send-email',44 headers := jsonb_build_object(45 'Content-Type', 'application/json',46 'Authorization', 'Bearer ' || current_setting('app.service_role_key')47 ),48 body := jsonb_build_object(49 'enrollment_id', enrollment.enrollment_id,50 'step_index', enrollment.current_step51 )52 );53 END IF;54 END IF;55 END LOOP;56END;57$$;5859-- Step 3: Register the cron job (runs every hour)60SELECT cron.schedule(61 'process-email-sequences',62 '0 * * * *',63 'SELECT process_due_email_steps()'64);Pro tip: Store the Edge Function base URL and service role key as PostgreSQL settings: SELECT set_config('app.edge_function_url', 'https://YOUR_PROJECT.supabase.co/functions/v1', true). This avoids hardcoding them in the function body.
Expected result: The pg_cron job is registered and visible in Supabase Dashboard → Database → Cron Jobs. The process_due_email_steps() function exists. Running it manually triggers sends for any due enrollment steps.
Build the campaign builder and template editor
Create the campaign management UI where admins build drip sequences, write email templates with variable preview, and enroll contacts in campaigns.
1Build a campaign management section with three pages.231. src/pages/Campaigns.tsx — Campaign list:4 - DataTable showing all campaigns with columns: Name, Status Badge, Steps count, Active Enrollments count, Created5 - 'New Campaign' Button opens a Dialog6 - Dialog: Campaign name, description, status Select7 - Clicking a campaign row navigates to /campaigns/:id892. src/pages/CampaignDetail.tsx — Campaign editor:10 - Tabs: 'Sequence', 'Enrollments', 'Analytics'11 - Sequence tab: shows the steps in order. Each step is a Card with: Day number Input, Template selector (Select with all templates), a remove button12 - 'Add Step' button appends a new step object to the steps array13 - Steps are sortable (drag-to-reorder using a simple up/down button approach)14 - Save button persists steps as JSONB to campaigns.steps15 - Enrollments tab: DataTable of contacts enrolled in this campaign with their current_step and status16 - 'Enroll Contacts' button opens a Dialog with a searchable contact list and multi-select Checkboxes17183. src/pages/Templates.tsx — Template editor:19 - List of templates on the left20 - Selecting a template shows an editor on the right21 - Fields: name, subject, from_name, from_email, preview_text22 - html_body: a Textarea with monospace font and a 'Preview' Button23 - Preview Dialog renders the HTML with sample variable values substituted24 - Available variables shown as Badges below the editor: {{first_name}}, {{last_name}}, {{email}}, {{company}}Pro tip: In the template preview, substitute real values from the first contact in your database. This gives a more realistic preview than using 'John Doe' placeholders, and helps catch missing data issues before sending.
Expected result: The campaigns list shows all campaigns. The campaign editor lets you build multi-step sequences. The template editor shows a live HTML preview. Enrolling contacts creates contact_enrollment rows.
Build the campaign analytics dashboard
Add an analytics tab to each campaign showing delivery volume, open rate, and click rate using Recharts charts fed by queries on send_logs.
1// src/components/CampaignAnalytics.tsx2import { useEffect, useState } from 'react'3import { supabase } from '@/integrations/supabase/client'4import { BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'5import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'67type StepStats = {8 step_index: number9 sent: number10 opened: number11 clicked: number12 failed: number13}1415type DayStats = {16 date: string17 sent: number18 opened: number19}2021export function CampaignAnalytics({ campaignId }: { campaignId: string }) {22 const [stepStats, setStepStats] = useState<StepStats[]>([])23 const [dayStats, setDayStats] = useState<DayStats[]>([])24 const [totals, setTotals] = useState({ sent: 0, opened: 0, clicked: 0, failed: 0 })2526 useEffect(() => {27 async function load() {28 const { data } = await supabase29 .from('send_logs')30 .select('step_index, status, opened_at, clicked_at, sent_at')31 .eq('campaign_id', campaignId)3233 if (!data) return3435 const byStep = data.reduce<Record<number, StepStats>>((acc, row) => {36 const s = acc[row.step_index] ?? { step_index: row.step_index, sent: 0, opened: 0, clicked: 0, failed: 0 }37 if (row.status === 'sent') s.sent++38 if (row.opened_at) s.opened++39 if (row.clicked_at) s.clicked++40 if (row.status === 'failed') s.failed++41 acc[row.step_index] = s42 return acc43 }, {})4445 setStepStats(Object.values(byStep).sort((a, b) => a.step_index - b.step_index))46 setTotals(data.reduce((acc, row) => ({47 sent: acc.sent + (row.status === 'sent' ? 1 : 0),48 opened: acc.opened + (row.opened_at ? 1 : 0),49 clicked: acc.clicked + (row.clicked_at ? 1 : 0),50 failed: acc.failed + (row.status === 'failed' ? 1 : 0),51 }), { sent: 0, opened: 0, clicked: 0, failed: 0 }))52 }53 load()54 }, [campaignId])5556 const openRate = totals.sent > 0 ? ((totals.opened / totals.sent) * 100).toFixed(1) : '0'5758 return (59 <div className='grid grid-cols-2 gap-4'>60 <Card><CardHeader><CardTitle>Open Rate</CardTitle></CardHeader>61 <CardContent><p className='text-3xl font-bold'>{openRate}%</p></CardContent>62 </Card>63 <Card><CardHeader><CardTitle>Total Sent</CardTitle></CardHeader>64 <CardContent><p className='text-3xl font-bold'>{totals.sent.toLocaleString()}</p></CardContent>65 </Card>66 <Card className='col-span-2'>67 <CardHeader><CardTitle>Sends by Step</CardTitle></CardHeader>68 <CardContent>69 <ResponsiveContainer width='100%' height={200}>70 <BarChart data={stepStats}>71 <CartesianGrid strokeDasharray='3 3' />72 <XAxis dataKey='step_index' tickFormatter={(v) => `Step ${v + 1}`} />73 <YAxis />74 <Tooltip />75 <Bar dataKey='sent' fill='#6366f1' name='Sent' />76 <Bar dataKey='opened' fill='#22c55e' name='Opened' />77 </BarChart>78 </ResponsiveContainer>79 </CardContent>80 </Card>81 </div>82 )83}Pro tip: Add a Resend webhook endpoint to track open and click events. Resend sends email.opened and email.clicked webhook events with the message ID. Match the message_id to send_logs.resend_message_id and update opened_at and clicked_at.
Expected result: The Analytics tab shows open rate, total sent, and a bar chart of sends and opens per step. Stats update when new sends complete.
Complete code
1import { useEffect, useState } from 'react'2import { supabase } from '@/integrations/supabase/client'3import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'4import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'5import { Badge } from '@/components/ui/badge'67type StepStats = {8 step_index: number9 sent: number10 opened: number11 clicked: number12 failed: number13}1415type Totals = { sent: number; opened: number; clicked: number; failed: number }1617export function CampaignAnalytics({ campaignId }: { campaignId: string }) {18 const [stepStats, setStepStats] = useState<StepStats[]>([])19 const [totals, setTotals] = useState<Totals>({ sent: 0, opened: 0, clicked: 0, failed: 0 })20 const [loading, setLoading] = useState(true)2122 useEffect(() => {23 async function load() {24 const { data } = await supabase25 .from('send_logs')26 .select('step_index, status, opened_at, clicked_at')27 .eq('campaign_id', campaignId)28 .eq('status', 'sent')2930 if (!data) { setLoading(false); return }3132 const byStep = data.reduce<Record<number, StepStats>>((acc, row) => {33 const i = row.step_index ?? 034 const s = acc[i] ?? { step_index: i, sent: 0, opened: 0, clicked: 0, failed: 0 }35 s.sent++36 if (row.opened_at) s.opened++37 if (row.clicked_at) s.clicked++38 acc[i] = s39 return acc40 }, {})4142 const sorted = Object.values(byStep).sort((a, b) => a.step_index - b.step_index)43 setStepStats(sorted)44 setTotals(sorted.reduce((acc, s) => ({45 sent: acc.sent + s.sent,46 opened: acc.opened + s.opened,47 clicked: acc.clicked + s.clicked,48 failed: acc.failed + s.failed,49 }), { sent: 0, opened: 0, clicked: 0, failed: 0 }))50 setLoading(false)51 }52 load()53 }, [campaignId])5455 const openRate = totals.sent > 0 ? ((totals.opened / totals.sent) * 100).toFixed(1) : '—'56 const clickRate = totals.opened > 0 ? ((totals.clicked / totals.opened) * 100).toFixed(1) : '—'5758 if (loading) return <div className='h-64 animate-pulse bg-muted rounded-lg' />5960 return (61 <div className='space-y-4'>62 <div className='grid grid-cols-3 gap-4'>63 <Card>64 <CardHeader><CardTitle className='text-sm text-muted-foreground'>Total Sent</CardTitle></CardHeader>65 <CardContent><p className='text-3xl font-bold'>{totals.sent.toLocaleString()}</p></CardContent>66 </Card>67 <Card>68 <CardHeader><CardTitle className='text-sm text-muted-foreground'>Open Rate</CardTitle></CardHeader>69 <CardContent><p className='text-3xl font-bold'>{openRate}%</p></CardContent>70 </Card>71 <Card>72 <CardHeader><CardTitle className='text-sm text-muted-foreground'>Click-to-Open Rate</CardTitle></CardHeader>73 <CardContent><p className='text-3xl font-bold'>{clickRate}%</p></CardContent>74 </Card>75 </div>76 <Card>77 <CardHeader><CardTitle>Performance by Step</CardTitle></CardHeader>78 <CardContent>79 <ResponsiveContainer width='100%' height={220}>80 <BarChart data={stepStats} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>81 <CartesianGrid strokeDasharray='3 3' className='stroke-border' />82 <XAxis dataKey='step_index' tickFormatter={(v) => `Step ${Number(v) + 1}`} />83 <YAxis />84 <Tooltip />85 <Bar dataKey='sent' fill='#6366f1' name='Sent' radius={[4, 4, 0, 0]} />86 <Bar dataKey='opened' fill='#22c55e' name='Opened' radius={[4, 4, 0, 0]} />87 </BarChart>88 </ResponsiveContainer>89 </CardContent>90 </Card>91 </div>92 )93}Customization ideas
Tag-based auto-enrollment
Add a trigger on contacts.tags that checks all active campaigns with trigger_type='tag'. When a tag matching campaign.trigger_config.tag is added to a contact, automatically insert a contact_enrollments row.
A/B subject line testing
Add a variants array to campaign steps: [{template_id, weight}]. In the send Edge Function, pick a variant based on weight using Math.random(). Log which variant was sent in send_logs to compare open rates.
Contact segmentation
Add a filter_config jsonb to campaigns defining which contacts qualify for enrollment. The enrollment function checks conditions like {tag: 'paid', source: 'webinar'} before creating the enrollment row.
Re-engagement campaigns
Create a special campaign for contacts whose last opened_at is older than 90 days. The pg_cron job checks send_logs grouped by contact and auto-enrolls cold contacts in the re-engagement campaign.
Webhook-triggered enrollment
Create an enroll-contact Edge Function that accepts a POST from external services (form submission, Stripe payment, etc.). The webhook payload includes email and optional tags. This lets any external system trigger a drip sequence.
Send time optimization
Track each contact's historical open times from send_logs. Add a best_send_hour column to contacts. Modify process_due_email_steps to only trigger sends when the current hour matches the contact's best_send_hour.
Common pitfalls
Pitfall: Calling pg_cron directly on the Edge Function URL without authentication
How to avoid: In process_due_email_steps(), include the Authorization header with the service role key: net.http_post(headers := jsonb_build_object('Authorization', 'Bearer ' || service_role_key, ...)).
Pitfall: Not adding a unique constraint to prevent duplicate sends
How to avoid: Add a unique constraint on send_logs(enrollment_id, step_index). The INSERT at the start of the send Edge Function will fail with a unique violation for duplicate calls, preventing double sends.
Pitfall: Using string concatenation to substitute template variables
How to avoid: Use a regex replace: htmlBody.replace(/\{\{(\w+)\}\}/g, (_, key) => variables[key] ?? ''). This only substitutes known variable names and never evaluates code.
Pitfall: Forgetting to add an unsubscribe mechanism
How to avoid: Append an unsubscribe link to every email HTML in the send Edge Function. Create a public /unsubscribe route that sets contacts.status = 'unsubscribed'. Check status = 'subscribed' before sending.
Pitfall: Running pg_cron more frequently than the Edge Function can process
How to avoid: Run pg_cron hourly, not every minute. Process contacts in batches (LIMIT 50 per cron run). For high volume, use a queue pattern with a separate drain function.
Best practices
- Always check contacts.status = 'subscribed' before sending — never send to unsubscribed, bounced, or complained contacts
- Add idempotency to your send Edge Function using the unique constraint on send_logs — safe to retry without duplicate sends
- Store rendered email HTML in send_logs alongside the template ID — this gives you a record of exactly what each contact received
- Test your pg_cron function manually by calling SELECT process_due_email_steps() from the SQL editor before trusting it to run automatically
- Set up Resend webhooks for bounce and complaint events to automatically update contacts.status to 'bounced' or 'complained'
- Monitor the send_logs.status distribution daily — a spike in 'failed' rows indicates an API key rotation or Resend outage
- Cap concurrent sends per pg_cron run with a LIMIT clause to prevent thundering herd problems at campaign launch
- Use preview_text in email templates — it shows as the email preview in most inboxes and significantly affects open rates
AI prompts to try
Copy these prompts to build this project faster.
I'm building a drip email system in Supabase where a pg_cron job calls process_due_email_steps() every hour. The function loops through due contact_enrollments and calls net.http_post to trigger an Edge Function for each one. At high contact volumes (10,000+ enrollments), this loop could time out or hit the pg_cron 30-second limit. What are the best patterns for batching this work? Should I use a cursor, a queue table, or process a limited batch per run?
Add bounce and complaint handling to my email automation system. Create a Supabase Edge Function at supabase/functions/resend-webhook/index.ts that handles Resend webhook events. For email.bounced events, set contacts.status = 'bounced'. For email.complained events, set contacts.status = 'complained'. Also update the corresponding send_logs.status. Register the Edge Function URL in Resend Dashboard → Webhooks. Add RESEND_WEBHOOK_SECRET to Secrets and verify the signature using the resend-node library.
My Lovable email automation uses pg_cron with net.http_post to call a Supabase Edge Function. The function requires an Authorization header with the service role JWT. I'm using current_setting('app.service_role_key') to read the key in the SQL function, but it keeps returning null. How do I correctly store and access a configuration value like a service role key from within a PostgreSQL function that's called by pg_cron? Is there a safer pattern than storing it as a pg setting?
Frequently asked questions
How do I prevent sending emails to unsubscribed contacts?
The send Edge Function checks contacts.status = 'subscribed' before calling the Resend API. If the status is anything other than 'subscribed', the function logs the send as 'skipped' and returns early. The pg_cron job also advances current_step so the enrollment continues through the sequence without resending.
What's the free tier limit for Resend?
Resend's free tier sends 3,000 emails per month, with a maximum of 100 emails per day. For production drip campaigns, you'll need at least the Pro plan ($20/month) which allows 50,000 emails per month. Resend requires a verified sending domain on paid plans for best deliverability.
How does pg_cron work in Supabase?
pg_cron is a PostgreSQL extension that runs scheduled SQL queries. Enable it in Supabase Dashboard → Database → Extensions. Schedules use standard cron syntax: '0 * * * *' runs every hour on the hour. Cron jobs run with database-level permissions, not as a specific user, which is why the process function needs SECURITY DEFINER to access application tables.
Can I send emails immediately when a contact signs up, without waiting for pg_cron?
Yes. For the day-0 step (the welcome email), call the send-email Edge Function directly from your sign-up flow after creating the enrollment row. Subsequent steps (day 3, day 7, etc.) are handled by pg_cron. This hybrid approach ensures the first email arrives instantly.
How do I handle contacts who are enrolled in multiple campaigns?
contact_enrollments has a unique constraint on (contact_id, campaign_id), so each contact has one enrollment per campaign. Multiple campaigns run in parallel — a contact can be in a welcome sequence and a nurture sequence simultaneously. The send Edge Function operates on individual enrollment rows, so parallel campaigns don't interfere.
What happens if the Edge Function fails for one contact?
The function catches errors and writes a failed status to send_logs. The contact's current_step is NOT advanced on failure. On the next pg_cron run, the same step will be attempted again. This gives failed sends up to 23 retries before the sequence falls a day behind. For persistent failures, the error_message column shows why.
Can I use SendGrid instead of Resend?
Yes. Replace the Resend API call in the send Edge Function with SendGrid's API: POST https://api.sendgrid.com/v3/mail/send with the Authorization: Bearer SENDGRID_API_KEY header. The request body format is slightly different but the logic is identical. Store SENDGRID_API_KEY in Cloud tab → Secrets.
Can I get help setting up drip sequences for my specific use case?
The RapidDev team builds and configures email automation systems for Lovable projects, including sequence design, template copywriting, and Resend domain verification. Reach out at rapidevelopers.com.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation