# How to Build an Email Automation with Replit

- Tool: How to Build with Replit
- Difficulty: Advanced
- Compatibility: Replit Free
- Last updated: April 2026

## TL;DR

Build a Mailchimp-alternative email automation platform in Replit in 2-4 hours. Create contact lists, design drip sequences with delay-based scheduling, send campaigns via SendGrid, and track opens and clicks through SendGrid's Event Webhook. Uses Express, PostgreSQL with Drizzle ORM, SendGrid API, and a Scheduled Deployment for the send queue.

## Before you start

- A Replit account (free tier is sufficient for the app, Replit Core for Scheduled Deployments)
- A SendGrid account (free at sendgrid.com, includes 100 emails/day forever)
- A verified sender email in SendGrid (Settings → Sender Authentication)
- SendGrid API key with Mail Send permission (Settings → API Keys)

## Step-by-step guide

### 1. Generate the project schema and routes with Replit Agent

The schema is the most complex part of this project. Getting it right in the first prompt saves hours of migration work. Use a detailed Agent prompt to generate everything at once.

```
// Prompt to type into Replit Agent:
// Build an email automation system with Express and PostgreSQL using Drizzle ORM.
// Create these tables in shared/schema.ts:
// - contacts: id serial pk, email text unique, name text, tags text[],
//   status text default 'active' (active/unsubscribed/bounced),
//   source text (import/form/api), subscribed_at timestamp, unsubscribed_at timestamp
// - email_lists: id serial pk, name text, description text, created_at timestamp
// - list_contacts: id serial pk, list_id integer references email_lists,
//   contact_id integer references contacts, added_at timestamp,
//   UNIQUE on (list_id, contact_id)
// - campaigns: id serial pk, name text, subject text, from_name text, from_email text,
//   body_html text, list_id integer references email_lists,
//   status text default 'draft' (draft/scheduled/sending/sent/cancelled),
//   scheduled_at timestamp, sent_at timestamp, created_at timestamp
// - campaign_sends: id serial pk, campaign_id integer references campaigns,
//   contact_id integer references contacts,
//   status text default 'pending' (pending/sent/delivered/bounced/failed),
//   sent_at timestamp, UNIQUE on (campaign_id, contact_id)
// - drip_sequences: id serial pk, name text, trigger_event text (signup/manual),
//   list_id integer references email_lists, is_active boolean default true, created_at timestamp
// - drip_steps: id serial pk, sequence_id integer references drip_sequences,
//   position integer, delay_hours integer, subject text, body_html text
// - drip_enrollments: id serial pk, sequence_id integer references drip_sequences,
//   contact_id integer references contacts, current_step integer default 0,
//   status text default 'active' (active/completed/paused/unsubscribed),
//   enrolled_at timestamp, next_send_at timestamp, UNIQUE on (sequence_id, contact_id)
// - email_events: id serial pk, campaign_send_id integer, event_type text
//   (open/click/bounce/unsubscribe), metadata jsonb, created_at timestamp
// Install @sendgrid/mail. Set up Replit Auth. Bind server to 0.0.0.0.
```

> Pro tip: After Agent generates the schema, open Drizzle Studio (database icon in sidebar) to verify all tables were created with the correct column types before adding data.

**Expected result:** Agent creates shared/schema.ts with all 9 tables, server/index.js, and installs @sendgrid/mail. The database initializes with Drizzle migrations.

### 2. Add SENDGRID_API_KEY to Secrets and build the campaign routes

Store the SendGrid API key securely before writing any code that uses it. Then build the campaign creation and scheduling routes — the POST /api/campaigns/:id/send route queues sends by creating campaign_sends rows.

```
// 1. Open Secrets panel (lock icon in sidebar)
//    Add: SENDGRID_API_KEY = your-sendgrid-api-key-here
//
// 2. Prompt to type into Replit Agent:
// Add campaign routes to server/routes/campaigns.js:
//
// POST /api/campaigns — create campaign: {name, subject, from_name, from_email, body_html, list_id}
// GET /api/campaigns — list user's campaigns with send counts per status
// PUT /api/campaigns/:id — update draft campaign
//
// POST /api/campaigns/:id/send — queue for sending:
//   1. Validate campaign status is 'draft' or 'scheduled'
//   2. Get all contacts in the campaign's list_id where contact status = 'active'
//   3. Insert one campaign_sends row per contact with status='pending'
//      Use INSERT ... ON CONFLICT (campaign_id, contact_id) DO NOTHING
//      to safely handle re-queuing attempts
//   4. Update campaign status to 'sending' if scheduled_at is null (send now)
//      or 'scheduled' if scheduled_at is in the future
//   5. Return {queued: contactCount}
//
// GET /api/campaigns/:id/stats — return:
//   total_sends, sent_count, open_count, click_count, bounce_count
//   calculated from campaign_sends joined with email_events
//   Calculate open_rate = open_count / sent_count * 100
```

**Expected result:** POST /api/campaigns/:id/send creates pending rows in campaign_sends for every active contact in the list. The campaign status changes to 'sending'.

### 3. Build the Scheduled Deployment send queue processor

The send queue is the heart of the system. A separate Replit Scheduled Deployment runs every 15 minutes, picks up pending sends, and batches them through SendGrid while respecting rate limits and retry logic.

```
const sgMail = require('@sendgrid/mail');
const { db } = require('./server/db');
const { campaigns, campaignSends, contacts, dripEnrollments, dripSteps, dripSequences } = require('./shared/schema');
const { eq, and, lte, sql } = require('drizzle-orm');

sgMail.setApiKey(process.env.SENDGRID_API_KEY);

async function processCampaignSends() {
  // Get campaigns with status='sending'
  const activeCampaigns = await db.select().from(campaigns)
    .where(eq(campaigns.status, 'sending'));

  for (const campaign of activeCampaigns) {
    // Get next 100 pending sends for this campaign
    const pendingSends = await db.select({
      sendId: campaignSends.id,
      email: contacts.email,
      name: contacts.name
    })
    .from(campaignSends)
    .innerJoin(contacts, eq(campaignSends.contactId, contacts.id))
    .where(and(eq(campaignSends.campaignId, campaign.id), eq(campaignSends.status, 'pending')))
    .limit(100);

    if (pendingSends.length === 0) {
      // All sends complete — mark campaign as sent
      await db.update(campaigns).set({ status: 'sent', sentAt: new Date() })
        .where(eq(campaigns.id, campaign.id));
      continue;
    }

    for (const send of pendingSends) {
      try {
        await sgMail.send({
          to: send.email,
          from: { name: campaign.fromName, email: campaign.fromEmail },
          subject: campaign.subject,
          html: campaign.bodyHtml.replace('{{name}}', send.name || 'there')
        });
        await db.update(campaignSends).set({ status: 'sent', sentAt: new Date() })
          .where(eq(campaignSends.id, send.sendId));
      } catch (err) {
        await db.update(campaignSends).set({ status: 'failed' })
          .where(eq(campaignSends.id, send.sendId));
        console.error(`Send failed for ${send.email}:`, err.response?.body || err.message);
      }
    }

    // Small delay between batches to respect SendGrid rate limits
    await new Promise(r => setTimeout(r, 1000));
  }
}

async function processDripEnrollments() {
  const dueEnrollments = await db.select()
    .from(dripEnrollments)
    .where(and(
      eq(dripEnrollments.status, 'active'),
      lte(dripEnrollments.nextSendAt, new Date())
    ));

  for (const enrollment of dueEnrollments) {
    const nextStep = await db.query.dripSteps.findFirst({
      where: and(
        eq(dripSteps.sequenceId, enrollment.sequenceId),
        eq(dripSteps.position, enrollment.currentStep)
      )
    });

    if (!nextStep) {
      await db.update(dripEnrollments).set({ status: 'completed' })
        .where(eq(dripEnrollments.id, enrollment.id));
      continue;
    }

    const contact = await db.query.contacts.findFirst({
      where: eq(contacts.id, enrollment.contactId)
    });

    if (contact?.status === 'active') {
      await sgMail.send({
        to: contact.email,
        from: { name: 'Your Company', email: process.env.FROM_EMAIL },
        subject: nextStep.subject,
        html: nextStep.bodyHtml.replace('{{name}}', contact.name || 'there')
      });
    }

    // Advance to next step
    const followingStep = await db.query.dripSteps.findFirst({
      where: and(
        eq(dripSteps.sequenceId, enrollment.sequenceId),
        eq(dripSteps.position, enrollment.currentStep + 1)
      )
    });

    await db.update(dripEnrollments).set({
      currentStep: enrollment.currentStep + 1,
      nextSendAt: followingStep
        ? new Date(Date.now() + followingStep.delayHours * 3600000)
        : null,
      status: followingStep ? 'active' : 'completed'
    }).where(eq(dripEnrollments.id, enrollment.id));
  }
}

(async () => {
  await processCampaignSends();
  await processDripEnrollments();
  process.exit(0);
})();
```

> Pro tip: The 1-second delay between batches of 100 sends keeps you under SendGrid's free tier limit of 100 emails/day. Upgrade to SendGrid's Essentials plan ($19.95/month, 50,000 emails) for production volume.

**Expected result:** Running node scripts/processQueue.js from the Shell sends pending campaign emails and advances drip enrollments. Schedule this to run every 15 minutes via a Scheduled Deployment.

### 4. Build the SendGrid Event Webhook for tracking

SendGrid can POST open and click events to your Express server. This webhook handler updates contact statuses and campaign statistics in real time. It only works after deployment.

```
// Prompt to type into Replit Agent:
// Add the SendGrid webhook handler to server/routes/webhooks.js:
//
// POST /api/webhooks/sendgrid
//   SendGrid sends an array of events in the request body
//   Each event has: email, event (open/click/bounce/unsubscribe/delivered),
//   sg_event_id, sg_message_id, timestamp, url (for click events)
//
//   For each event in the array:
//   1. Find the matching campaign_sends row by joining contacts.email = event.email
//      and filtering by recent sent_at (within 30 days)
//   2. If found, insert into email_events: {campaign_send_id, event_type: event.event,
//      metadata: {url: event.url}, created_at: new Date(event.timestamp * 1000)}
//   3. If event.event === 'bounce' or 'unsubscribe':
//      UPDATE contacts SET status = (event.event === 'bounce' ? 'bounced' : 'unsubscribed'),
//      unsubscribed_at = now() WHERE email = event.email
//   4. Always return 200 — SendGrid retries on non-2xx responses
//
// Register the webhook URL in SendGrid:
// Settings → Mail Settings → Event Webhook
// HTTP Post URL: https://your-deployment-url.repl.co/api/webhooks/sendgrid
// Select: Delivered, Opened, Clicked, Bounced, Unsubscribed
```

> Pro tip: SendGrid sends events as an array — sometimes batching multiple events in one POST. Your handler must loop over req.body (an array), not treat it as a single event object.

**Expected result:** After deployment and webhook registration in SendGrid, opening a test email updates the campaign stats. Check the email_events table in Drizzle Studio to see open/click events logged.

### 5. Build the drip sequence builder and contact import

Drip sequences are chains of timed emails. The sequence builder lets you create steps with delay_hours between them. The contact import handles CSV uploads and adds contacts to lists in bulk.

```
// Prompt to type into Replit Agent:
// Add these routes to server/routes/contacts.js:
//
// POST /api/contacts/import — CSV import
//   Accept multipart form with CSV file using multer
//   Parse CSV with csv-parse: expect columns 'email' and optionally 'name', 'tags'
//   For each row: INSERT INTO contacts (email, name, tags) ON CONFLICT (email) DO NOTHING
//   Return {imported: count, skipped: duplicateCount}
//
// POST /api/lists/:id/contacts — add contact by email to a list
//   Find or create contact by email, then insert list_contacts row
//
// Add these routes to server/routes/drip.js:
//
// POST /api/drip-sequences — create sequence: {name, trigger_event, list_id}
// POST /api/drip-sequences/:id/steps — add step: {delay_hours, subject, body_html, position}
// GET /api/drip-sequences/:id — sequence with all steps ordered by position
// DELETE /api/drip-sequences/:id/steps/:stepId — remove a step
//
// POST /api/drip-sequences/:id/enroll — enroll a contact
//   Body: {contactId} — contact must be in the sequence's list
//   INSERT INTO drip_enrollments (sequence_id, contact_id, current_step=0,
//     next_send_at = now() + first step's delay_hours)
//   ON CONFLICT (sequence_id, contact_id) DO NOTHING
//
// GET /api/drip-sequences/:id/enrollments — list enrollments with contact info and status
```

**Expected result:** Importing a CSV of 50 contacts adds them to the contacts table. Enrolling them in a drip sequence creates rows in drip_enrollments with next_send_at set to the first step's delay from now.

### 6. Deploy on Reserved VM and set up the Scheduled Deployment

Deploy the main app and set up a Scheduled Deployment to run the send queue processor every 15 minutes. Reserved VM ensures the SendGrid webhook receiver is always available.

```
// Deployment plan:
//
// 1. Open Secrets panel (lock icon) and verify:
//    SENDGRID_API_KEY, SESSION_SECRET, FROM_EMAIL (verified sender)
//    DATABASE_URL (auto-set by Replit)
//
// 2. Ensure server/index.js binds to 0.0.0.0:
//    app.listen(process.env.PORT || 3000, '0.0.0.0', () => {...})
//
// 3. Deploy main app on Reserved VM:
//    Deploy → Reserved VM (ensures webhook receiver is always-on)
//    Note your deployment URL: https://your-repl.your-username.repl.co
//
// 4. Create Scheduled Deployment:
//    Deploy → Scheduled → every 15 minutes
//    Run command: node scripts/processQueue.js
//    This runs the send queue processor on a cron schedule
//
// 5. Register SendGrid webhook:
//    SendGrid Dashboard → Settings → Mail Settings → Event Webhook
//    HTTP Post URL: https://your-deployment-url.repl.co/api/webhooks/sendgrid
//    Check: Delivered, Opened, Clicked, Bounced, Unsubscribed → Save
//
// 6. Test: create a contact, add to a list, create a campaign, queue sends
//    Wait for next Scheduled Deployment run (or trigger manually from Scripts)
//    Check Drizzle Studio: campaign_sends should show status='sent'
```

> Pro tip: Replit's free plan does not include Scheduled Deployments. You need Replit Core ($25/month) to set up the automated send queue. Alternatively, trigger processQueue.js manually from the Shell during testing.

**Expected result:** The main app is live on Reserved VM, the scheduled processor runs every 15 minutes, and the SendGrid webhook delivers open/click events to your endpoint.

## Complete code example

File: `scripts/processQueue.js`

```javascript
const sgMail = require('@sendgrid/mail');
const { db } = require('./server/db');
const { campaigns, campaignSends, contacts, dripEnrollments, dripSteps } = require('./shared/schema');
const { eq, and, lte } = require('drizzle-orm');

sgMail.setApiKey(process.env.SENDGRID_API_KEY);

async function processCampaignSends() {
  const activeCampaigns = await db.select().from(campaigns)
    .where(eq(campaigns.status, 'sending'));

  for (const campaign of activeCampaigns) {
    const pendingSends = await db.select({
      sendId: campaignSends.id,
      email: contacts.email,
      name: contacts.name
    }).from(campaignSends)
      .innerJoin(contacts, eq(campaignSends.contactId, contacts.id))
      .where(and(eq(campaignSends.campaignId, campaign.id), eq(campaignSends.status, 'pending')))
      .limit(100);

    if (pendingSends.length === 0) {
      await db.update(campaigns).set({ status: 'sent', sentAt: new Date() })
        .where(eq(campaigns.id, campaign.id));
      continue;
    }

    for (const send of pendingSends) {
      try {
        await sgMail.send({
          to: send.email,
          from: { name: campaign.fromName, email: campaign.fromEmail },
          subject: campaign.subject,
          html: campaign.bodyHtml.replace(/{{name}}/g, send.name || 'there')
        });
        await db.update(campaignSends)
          .set({ status: 'sent', sentAt: new Date() })
          .where(eq(campaignSends.id, send.sendId));
      } catch (err) {
        await db.update(campaignSends).set({ status: 'failed' })
          .where(eq(campaignSends.id, send.sendId));
        console.error('Send failed:', err.response?.body || err.message);
      }
      await new Promise(r => setTimeout(r, 50));
    }
  }
}

async function processDripEnrollments() {
  const due = await db.select().from(dripEnrollments)
    .where(and(eq(dripEnrollments.status, 'active'), lte(dripEnrollments.nextSendAt, new Date())));

  for (const enrollment of due) {
    const step = await db.query.dripSteps.findFirst({
      where: and(eq(dripSteps.sequenceId, enrollment.sequenceId), eq(dripSteps.position, enrollment.currentStep))
    });
    if (!step) {
      await db.update(dripEnrollments).set({ status: 'completed' }).where(eq(dripEnrollments.id, enrollment.id));
      continue;
    }
```

## Common mistakes

- **Sending all campaign emails in a single API request** — A campaign to 5,000 contacts would take minutes to send inline, triggering Replit's 30-second request timeout and leaving some contacts never emailed. Fix: Always queue sends (insert pending rows) in the API, then process them asynchronously via a Scheduled Deployment. The API returns immediately; the scheduler handles delivery in batches.
- **Not handling bounced contacts** — Repeatedly sending to bounced email addresses damages your sender reputation and can get your SendGrid account suspended. Fix: The SendGrid Event Webhook bounce events update contact status to 'bounced'. The campaign send queue skips contacts where status != 'active'. Always process bounce events promptly.
- **Forgetting the PostgreSQL sleep reconnection issue in the scheduled processor** — If no web requests hit the database for 5+ minutes before the Scheduled Deployment runs, the first DB query fails with a connection timeout. Fix: Configure the PostgreSQL Pool with connectionTimeoutMillis: 5000 and implement a retry on connection error. The pool reconnects automatically on the next call after a sleep.
- **Storing the SendGrid API key in code instead of Replit Secrets** — Committing API keys to your Replit project makes them visible to anyone who can view your Replit code. SendGrid keys can be used to send emails from your account and incur charges. Fix: Always store SENDGRID_API_KEY in the Secrets panel (lock icon in sidebar). Access it in code only via process.env.SENDGRID_API_KEY.

## Best practices

- Process campaign sends in batches of 100 with a 50ms delay between each send to stay within SendGrid's rate limits on free tier accounts.
- Use INSERT ... ON CONFLICT DO NOTHING when creating campaign_sends rows to safely handle re-queuing attempts without creating duplicate send records.
- Always include an unsubscribe mechanism in every marketing email — this is legally required in most countries (CAN-SPAM, GDPR). Handle the SendGrid unsubscribe webhook to automatically update contact status.
- Store the SendGrid API key and sender email address in Replit Secrets (lock icon), and replicate them in Deployment Secrets before going live.
- Use Drizzle Studio (database icon in sidebar) to monitor the campaign_sends table during testing — you can see which sends are pending, sent, or failed in real time.
- Deploy the main app on Reserved VM rather than Autoscale — the SendGrid Event Webhook requires an always-on server to receive tracking events reliably.
- Test your drip sequence end-to-end by enrolling yourself as a contact with a short delay_hours (e.g., 0.01 hours = 36 seconds) so you receive the first email within a minute.

## Frequently asked questions

### Can I use Resend or Postmark instead of SendGrid?

Yes. Replace @sendgrid/mail with the Resend SDK (resend) or node-postmark. The send queue processor only needs to change the sendEmail function — the database schema, routes, and drip logic stay the same. Resend offers a generous free tier (3,000 emails/month) and is a popular SendGrid alternative.

### How do I avoid my emails going to spam?

Three main factors: (1) verify your sender domain in SendGrid (Settings → Sender Authentication → Domain Authentication), (2) only email contacts who opted in, (3) honor unsubscribes immediately. Also set up SPF, DKIM, and DMARC DNS records on your domain — SendGrid's Domain Authentication wizard does this for you.

### Do I need a paid Replit plan for Scheduled Deployments?

Yes. Replit Core ($25/month) includes Scheduled Deployments. On the free plan, you can still run node scripts/processQueue.js manually from the Shell, or trigger it from a webhook call. For production use, Replit Core is required for the automated 15-minute cron.

### What's the maximum list size this handles?

The queue-based architecture handles lists of any size. A 100,000-contact campaign creates 100,000 rows in campaign_sends, then processes them in batches of 100 across many Scheduled Deployment runs. PostgreSQL's 10GB free limit stores approximately 1 million campaign_sends rows before cleanup is needed.

### How do I implement double opt-in?

Add a confirmed_at column to contacts with default null. On form signup, set status='pending'. Send a confirmation email with a token link. A /confirm?token=xxx route verifies the token and sets confirmed_at = now() and status='active'. The campaign queue only processes contacts where status='active'.

### Why must the app be on Reserved VM instead of Autoscale?

Autoscale scales to zero when there's no traffic, which means your Express server is not running. SendGrid's Event Webhook delivers opens and clicks asynchronously — if your server is asleep when a webhook arrives, you miss the tracking event. Reserved VM keeps the server always-on so no events are lost.

### Can RapidDev help build a custom email marketing platform?

Yes. RapidDev has built 600+ apps and can add features like visual drag-and-drop email builder, advanced segmentation with behavioral triggers, and multi-team workspaces. Book a free consultation at rapidevelopers.com.

### How do I handle GDPR compliance?

Three requirements: (1) record consent source (form/import/api) in the contacts.source field with a timestamp, (2) provide a working unsubscribe link in every email, (3) process unsubscribe requests within 24 hours. The SendGrid unsubscribe webhook handles #2 and #3 automatically in this build.

---

Source: https://www.rapidevelopers.com/how-to-build-replit/email-automation
© RapidDev — https://www.rapidevelopers.com/how-to-build-replit/email-automation
