Skip to main content
RapidDev - Software Development Agency
flutterflow-tutorials

How to Set Up Automated Email Campaigns Triggered by User Actions in FlutterFlow

Build automated email campaigns in FlutterFlow by triggering Firebase Cloud Functions on Firestore document creation or field changes. Welcome emails fire on user document creation, purchase emails on order status change. Use Cloud Scheduler for time-delayed drip campaigns and abandoned cart sequences. Track sent emails in a Firestore email_log collection to prevent duplicates from function retries.

What you'll learn

  • How to trigger welcome and purchase emails from Firestore document events using Cloud Functions
  • How to set up time-delayed drip campaigns using Cloud Scheduler
  • How to prevent duplicate emails caused by Cloud Function retries
  • How to build an abandoned cart re-engagement sequence with Firestore triggers
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner11 min read35-50 minFlutterFlow Free+March 2026RapidDev Engineering Team
TL;DR

Build automated email campaigns in FlutterFlow by triggering Firebase Cloud Functions on Firestore document creation or field changes. Welcome emails fire on user document creation, purchase emails on order status change. Use Cloud Scheduler for time-delayed drip campaigns and abandoned cart sequences. Track sent emails in a Firestore email_log collection to prevent duplicates from function retries.

Behavioral Email Automation Without a Third-Party Service

Email automation in FlutterFlow apps works through Firebase Cloud Functions that react to Firestore document events. When a user signs up, a document is created in the users collection — a Firestore onCreate trigger fires a Cloud Function that sends the welcome email via SendGrid or Resend. When an order is placed, the orders collection gets a new document — a trigger sends a receipt. The critical mistake is sending emails synchronously inside a Firestore trigger without idempotency protection. Cloud Functions can be retried on failure, and without a deduplication check, the same welcome email gets sent twice or three times. This tutorial shows how to build the full automation stack with proper deduplication.

Prerequisites

  • FlutterFlow project with Firebase connected
  • Firebase project on Blaze plan (required for Cloud Functions and outbound HTTP calls)
  • SendGrid or Resend account with a verified sender domain
  • Email API key stored in Cloud Functions environment config
  • Basic familiarity with Firebase Cloud Functions (Node.js)

Step-by-step guide

1

Create an email_log Firestore collection for deduplication

Before writing any Cloud Function, create a Firestore collection named email_log. Each document has a document ID formatted as [user_id]_[email_type] (e.g., abc123_welcome). Fields: user_id (String), email_type (String), sent_at (Timestamp), recipient_email (String), status (String — sent/failed). Before sending any email in a Cloud Function, check if a document with this ID already exists in email_log. If it does, the email was already sent — return early without sending. If it does not exist, create the log entry first (using a Firestore transaction or set with no-overwrite), then send the email. This pattern guarantees at-most-once delivery even when the function is retried.

Expected result: Firestore shows an email_log collection; manually creating a test document with an email_type of welcome proves the schema is correct.

2

Build the welcome email trigger

Write a Cloud Function that fires on onCreate of a document in the users collection. The function reads the new user document for email, display_name, and any welcome data. It checks the email_log for a [uid]_welcome entry — if found, return. If not found, create the log entry and call the SendGrid or Resend API with your welcome email template. The email should personalise the greeting with the user's display name, include a clear call to action (complete their profile, explore a key feature, or start a trial), and include an unsubscribe link as required by CAN-SPAM law. Deploy with firebase deploy --only functions:sendWelcomeEmail.

functions/src/emailTriggers.ts
1// functions/src/emailTriggers.ts
2import * as functions from 'firebase-functions';
3import * as admin from 'firebase-admin';
4
5const db = admin.firestore();
6
7export const sendWelcomeEmail = functions.firestore
8 .document('users/{userId}')
9 .onCreate(async (snap, context) => {
10 const userId = context.params.userId;
11 const user = snap.data();
12 const logId = `${userId}_welcome`;
13
14 // Idempotency check
15 const logRef = db.collection('email_log').doc(logId);
16 const existing = await logRef.get();
17 if (existing.exists) return;
18
19 // Reserve the log entry before sending
20 await logRef.set({
21 user_id: userId,
22 email_type: 'welcome',
23 recipient_email: user.email,
24 sent_at: admin.firestore.FieldValue.serverTimestamp(),
25 status: 'pending',
26 });
27
28 try {
29 await sendEmail({
30 to: user.email,
31 subject: `Welcome to the app, ${user.display_name}!`,
32 html: buildWelcomeHtml(user.display_name),
33 });
34 await logRef.update({ status: 'sent' });
35 } catch (err) {
36 await logRef.update({ status: 'failed', error: String(err) });
37 throw err; // Re-throw so Firebase retries the function
38 }
39 });
40
41async function sendEmail(opts: { to: string; subject: string; html: string }) {
42 const res = await fetch('https://api.resend.com/emails', {
43 method: 'POST',
44 headers: {
45 Authorization: `Bearer ${functions.config().resend.key}`,
46 'Content-Type': 'application/json',
47 },
48 body: JSON.stringify({
49 from: 'hello@yourapp.com',
50 to: opts.to,
51 subject: opts.subject,
52 html: opts.html,
53 }),
54 });
55 if (!res.ok) throw new Error(`Email API error: ${res.status}`);
56}
57
58function buildWelcomeHtml(name: string): string {
59 return `<h1>Welcome, ${name}!</h1><p>Get started by completing your profile.</p>`;
60}

Expected result: Creating a new user document in Firestore (or a new Firebase Auth user if your function uses the Auth trigger) triggers the function and sends a welcome email visible in SendGrid/Resend activity logs.

3

Set up a purchase confirmation email on order status change

Write a Cloud Function that fires on onUpdate of documents in an orders collection. Check if the status field changed from pending to paid (or from any state to completed). This field transition approach prevents sending a receipt on every field update. The function checks email_log for [order_id]_receipt — if found, skip. If not found, log and send a receipt email with the order details: items, prices, shipping address, and a support contact. Load the user's email from the users collection using the order's user_id field. Include the order ID in the email subject for easy customer support reference.

Expected result: Updating an order's status field to 'paid' in Firestore triggers the function and sends a receipt email within 5-10 seconds.

4

Create a drip campaign with Cloud Scheduler

A drip campaign sends a sequence of emails over time: day 1 welcome, day 3 tips, day 7 engagement check. Implement this using Cloud Scheduler running a Cloud Function every day. The function queries users who signed up exactly N days ago (using created_at timestamp range) and have not received a specific drip email yet (checked against email_log). For each matching user, send the appropriate email and log it. The daily function runs as: functions.pubsub.schedule('every 24 hours').onRun(). Set the schedule using a cron expression ('0 10 * * *' for 10am UTC daily) so emails arrive at a consistent time. This approach scales to thousands of users without any per-user Firestore triggers.

functions/src/dripCampaign.ts
1export const sendDripEmails = functions.pubsub
2 .schedule('0 10 * * *')
3 .timeZone('America/New_York')
4 .onRun(async () => {
5 const drips = [
6 { daysAfterSignup: 3, emailType: 'day3_tips', subject: '3 tips for getting started' },
7 { daysAfterSignup: 7, emailType: 'day7_check', subject: 'How is it going?' },
8 { daysAfterSignup: 14, emailType: 'day14_feature', subject: 'Have you tried this feature?' },
9 ];
10
11 for (const drip of drips) {
12 const cutoffStart = new Date();
13 cutoffStart.setDate(cutoffStart.getDate() - drip.daysAfterSignup - 1);
14 const cutoffEnd = new Date();
15 cutoffEnd.setDate(cutoffEnd.getDate() - drip.daysAfterSignup);
16
17 const usersSnap = await db.collection('users')
18 .where('created_at', '>=', admin.firestore.Timestamp.fromDate(cutoffStart))
19 .where('created_at', '<', admin.firestore.Timestamp.fromDate(cutoffEnd))
20 .where('email_opted_in', '==', true)
21 .get();
22
23 for (const userDoc of usersSnap.docs) {
24 const user = userDoc.data();
25 const logId = `${userDoc.id}_${drip.emailType}`;
26 const logRef = db.collection('email_log').doc(logId);
27 const existing = await logRef.get();
28 if (existing.exists) continue;
29
30 await logRef.set({ status: 'pending', sent_at: admin.firestore.FieldValue.serverTimestamp() });
31 try {
32 await sendEmail({ to: user.email, subject: drip.subject, html: `<p>Hi ${user.display_name}</p>` });
33 await logRef.update({ status: 'sent' });
34 } catch (e) {
35 await logRef.update({ status: 'failed' });
36 }
37 }
38 }
39 });

Expected result: Cloud Scheduler shows the function running daily; email_log documents with day3_tips and day7_check appear for users at the correct intervals.

5

Add an abandoned cart re-engagement email

An abandoned cart email sends to users who added items to a cart but did not complete a purchase after 24 hours. Implement with a Cloud Scheduler function running hourly. Query carts where status equals active and updated_at is between 24 and 25 hours ago. For each matching cart, check if an abandoned_cart email was already sent for that cart ID. If not, send the email with the cart contents and a checkout link. In FlutterFlow, ensure the cart document has a user_id field and that items are stored as an array of objects with name and price fields. The email should show the cart contents and include a direct checkout link using your app's deep link URL.

Expected result: A cart document with status 'active' and updated_at 24+ hours ago triggers the abandoned cart email function on the next hourly run.

Complete working example

functions/src/emailTriggers.ts
1import * as functions from 'firebase-functions';
2import * as admin from 'firebase-admin';
3
4admin.initializeApp();
5const db = admin.firestore();
6
7async function sendViaResend(
8 to: string,
9 subject: string,
10 html: string,
11): Promise<void> {
12 const res = await fetch('https://api.resend.com/emails', {
13 method: 'POST',
14 headers: {
15 Authorization: `Bearer ${functions.config().resend.key}`,
16 'Content-Type': 'application/json',
17 },
18 body: JSON.stringify({ from: 'hello@yourapp.com', to, subject, html }),
19 });
20 if (!res.ok) {
21 const body = await res.text();
22 throw new Error(`Resend API ${res.status}: ${body}`);
23 }
24}
25
26async function sendIdempotent(
27 logId: string,
28 userId: string,
29 email: string,
30 subject: string,
31 html: string,
32): Promise<void> {
33 const logRef = db.collection('email_log').doc(logId);
34 const snap = await logRef.get();
35 if (snap.exists) return; // Already sent
36 await logRef.set({
37 user_id: userId,
38 recipient_email: email,
39 status: 'pending',
40 sent_at: admin.firestore.FieldValue.serverTimestamp(),
41 });
42 try {
43 await sendViaResend(email, subject, html);
44 await logRef.update({ status: 'sent' });
45 } catch (err) {
46 await logRef.update({ status: 'failed', error: String(err) });
47 throw err;
48 }
49}
50
51// Welcome email — fires on new user document
52export const sendWelcomeEmail = functions.firestore
53 .document('users/{userId}')
54 .onCreate(async (snap, context) => {
55 const u = snap.data();
56 await sendIdempotent(
57 `${context.params.userId}_welcome`,
58 context.params.userId,
59 u.email,
60 `Welcome, ${u.display_name}!`,
61 `<h1>Welcome!</h1><p>Thanks for joining. <a href="https://yourapp.com/start">Get started here.</a></p>`,
62 );
63 });
64
65// Receipt email — fires when order status becomes 'paid'
66export const sendReceiptEmail = functions.firestore
67 .document('orders/{orderId}')
68 .onUpdate(async (change, context) => {
69 const before = change.before.data();
70 const after = change.after.data();
71 if (before.status === after.status || after.status !== 'paid') return;
72 const orderId = context.params.orderId;
73 const userDoc = await db.collection('users').doc(after.user_id).get();
74 const user = userDoc.data()!;
75 await sendIdempotent(
76 `${orderId}_receipt`,
77 after.user_id,
78 user.email,
79 `Your receipt — Order #${orderId.slice(-6).toUpperCase()}`,
80 `<h2>Order confirmed!</h2><p>Total: $${after.total_cents / 100}</p>`,
81 );
82 });

Common mistakes

Why it's a problem: Sending emails synchronously inside a Firestore trigger without an idempotency check

How to avoid: Always check the email_log collection before sending. Use a document ID of [user_id]_[email_type] so the check is O(1). Create the log entry before sending the email, then update the status after the email API call.

Why it's a problem: Querying all users in the drip function and sending emails without checking opt-in status

How to avoid: Add an email_opted_in boolean field to each user document (set to true by default for transactional emails, only with explicit consent for marketing). Always filter queries with .where('email_opted_in', '==', true) for marketing campaigns.

Why it's a problem: Hardcoding email HTML in the Cloud Function

How to avoid: Store email templates in a Firestore email_templates collection. The function reads the template by name, substitutes variables using simple string replacement, and sends the rendered HTML. Non-developers can edit templates in the Firebase console.

Best practices

  • Always implement idempotency in email-sending Cloud Functions to prevent duplicate emails on function retries.
  • Store email templates in Firestore so marketing content can be updated without redeploying functions.
  • Add email_opted_in and marketing_opted_in fields to user documents and always respect them in drip campaigns.
  • Log every email sent to an email_log collection — this is your audit trail for debugging and compliance.
  • Include an unsubscribe link in every marketing email as required by CAN-SPAM law.
  • Use a verified sending domain (not a free Gmail or Yahoo address) to ensure email deliverability.
  • Monitor your email sending reputation in SendGrid/Resend dashboard — high bounce or spam rates hurt all future deliverability.
  • Test drip email timing by creating test user accounts with created_at timestamps set to past dates.

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I am building an automated email campaign system for a FlutterFlow app using Firebase Cloud Functions and Resend (resend.com) for email sending. Write a Cloud Function that: (1) triggers on Firestore onCreate for the users collection, (2) checks a Firestore email_log collection for an existing entry with document ID [userId]_welcome to prevent duplicate sends, (3) creates the log entry as 'pending' before sending, (4) calls the Resend API with a personalised welcome email, and (5) updates the log entry to 'sent' or 'failed'. Include the complete TypeScript code with proper error handling.

FlutterFlow Prompt

In FlutterFlow I want to trigger an email when a user's subscription status changes from 'free' to 'pro'. In my Firestore users collection, there is a subscription_status field. Build the Cloud Function that fires on onUpdate of user documents, detects the free-to-pro transition specifically, checks email_log for deduplication, and sends a 'Welcome to Pro' email with the user's name. Also explain how to set up the Firebase Cloud Function environment config for the Resend API key.

Frequently asked questions

Do I need a paid email service like SendGrid, or can I use Gmail SMTP?

Gmail SMTP is not suitable for production email automation. Gmail has a 500-email daily sending limit, poor deliverability for bulk sending, and does not provide the tracking, analytics, or unsubscribe management required for CAN-SPAM compliance. Use SendGrid (100 free emails/day), Resend (3,000 free emails/month), or Mailgun (1,000 free emails/month) instead.

How do I test email triggers without waiting for real user signups?

Use the Firebase Emulator Suite to test Firestore triggers locally. Run firebase emulators:start and create user documents directly in the emulator's Firestore UI — the trigger fires immediately. For testing production triggers without real users, write a one-off Cloud Function that creates a test user document with specific test data, runs the trigger logic, and then cleans up.

Why do my Cloud Functions sometimes send the same email twice?

Firebase guarantees at-least-once execution for Firestore triggers, meaning the function can run more than once for the same event in case of retries. The email_log deduplication pattern from Step 1 prevents this. If you are seeing duplicates despite implementing deduplication, check that your log entry creation and status check are using the correct document ID format and that the Firestore get() is using the same ID as the set().

How do I handle email bounces and unsubscribes?

Configure SendGrid or Resend webhooks to POST bounce and unsubscribe events to a Cloud Function. When a bounce webhook arrives, update the user's Firestore document to set email_deliverable: false to stop future sends. When an unsubscribe arrives, set email_opted_in: false. Both changes prevent the user from receiving future emails without deleting their account.

What is the maximum number of emails I can send per Cloud Function invocation?

Cloud Functions have a default timeout of 60 seconds (configurable up to 9 minutes). If your drip function processes thousands of users, it will time out before completing. Process users in batches of 100-500 per invocation, or use Cloud Tasks to dispatch individual send tasks for each user, which scales to millions of users without timeout issues.

Can I preview email templates in FlutterFlow before sending?

FlutterFlow itself does not have an email template previewer. Write the HTML template to a Firestore document, then create a simple preview page in FlutterFlow that loads the template string and displays it in a WebView Custom Widget. You can also use tools like Litmus or Email on Acid to preview your HTML email across 90+ email clients before deploying.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.