How to Automate Gmail with Google Apps Script (Complete 2026 Guide)

How to Automate Gmail with Google Apps Script (Complete 2026 Guide)

If you've ever found yourself manually sorting emails, sending the same follow-up five times a week, or copy-pasting data between Gmail and Sheets, you already know why you need to automate Gmail with Google Apps Script. I've built dozens of these automations for clients and internal tools. The ROI is almost always immediate. Less busywork, fewer mistakes, and emails that actually go out on time.

This guide walks through the real patterns I use in production. Not toy examples. Actual scripts you can adapt for sending templated emails, auto-labeling inbound messages, bulk mail merges from Sheets, scheduled sends, and auto-replies. We'll also cover the stuff most tutorials skip: error handling, quota limits, and security.


What You Can Automate in Gmail with Apps Script

Google Apps Script gives you programmatic access to nearly every Gmail operation. Here's what's actually useful:

  • Sending emails (plain text, HTML templates, attachments)
  • Reading and parsing incoming messages to extract data automatically
  • Labeling and filtering based on content, sender, or custom logic that goes beyond Gmail's built-in filters
  • Bulk personalized sends powered by Google Sheets data
  • Scheduled operations like time-based triggers for delayed sends, daily digests, or cleanup scripts
  • Auto-replies that respond to emails matching specific patterns without you lifting a finger

So why pick this over Zapier or Make? You own the code, it runs on Google's infrastructure for free, and you get direct access to the entire Google Workspace API surface. That matters when things break at 2 AM and you need to debug.


Prerequisites: Setting Up Your First Script

Opening the Apps Script Editor

Two ways in:

  1. From Google Drive: New → More → Google Apps Script
  2. From Sheets (recommended for data-driven scripts): Extensions → Apps Script

Option two is better when your script talks to spreadsheet data. The script is container-bound, which means it has implicit access to the parent Sheet without extra authorization prompts. Less friction, fewer popup windows.

Once you're in the editor, you'll see a default myFunction() stub. Delete it. We're writing real code.

GmailApp vs. Gmail Advanced Service

Apps Script offers two ways to interact with Gmail.

GmailApp is the built-in service. No setup required. Covers 90% of use cases.

// Simple and direct
GmailApp.sendEmail('recipient@example.com', 'Subject', 'Body text');javascript

Gmail Advanced Service is a wrapper around the full Gmail REST API. You need to enable it under Services → Gmail API. Use this when you need access to raw MIME messages, batch operations beyond what GmailApp supports, or fine-grained control over message headers.

For everything in this guide, GmailApp is enough. I only pull in the advanced service when a client needs something unusual like modifying message headers or working with delegated mailboxes.


Send Emails Programmatically

Plain Text Emails

The simplest gmail automation script starts here:

function sendBasicEmail() {
  GmailApp.sendEmail(
    'client@example.com',
    'Project Update - March 2026',
    'Hi there,\n\nJust a quick update on the project status...\n\nBest,\nDaniel'
  );
}javascript

Three arguments: recipient, subject, body. But you'll almost never send plain text in production.

HTML Emails with Templates

Real-world automated emails need formatting. Here's the pattern I use with an HTML body and a plain text fallback:

function sendHtmlEmail() {
  const recipient = 'client@example.com';
  const subject = 'Your Weekly Report';

  const htmlBody = `
    <div style="font-family: Arial, sans-serif; max-width: 600px;">
      <h2 style="color: #1a73e8;">Weekly Performance Report</h2>
      <p>Here are your numbers for the week of March 17-21:</p>
      <table style="border-collapse: collapse; width: 100%;">
        <tr style="background: #f8f9fa;">
          <td style="padding: 8px; border: 1px solid #ddd;"><strong>Metric</strong></td>
          <td style="padding: 8px; border: 1px solid #ddd;"><strong>Value</strong></td>
        </tr>
        <tr>
          <td style="padding: 8px; border: 1px solid #ddd;">Emails Sent</td>
          <td style="padding: 8px; border: 1px solid #ddd;">1,247</td>
        </tr>
        <tr>
          <td style="padding: 8px; border: 1px solid #ddd;">Open Rate</td>
          <td style="padding: 8px; border: 1px solid #ddd;">34.2%</td>
        </tr>
      </table>
    </div>
  `;

  GmailApp.sendEmail(recipient, subject, 'View this email in HTML.', {
    htmlBody: htmlBody,
    name: 'Daniel Diaz',
    replyTo: 'daniel@diazovate.com'
  });
}javascript

The fourth argument is an options object. Besides htmlBody, you can set attachments, bcc, cc, name (sender display name), and replyTo. These options are what separate a script that sends email from one that sends professional email.


Auto-Label and Filter Incoming Emails

Gmail filters are limited. You can't filter by regex. You can't filter based on combinations of body content and attachment types. And you definitely can't filter based on external data. Apps Script fixes all of that.

function autoLabelEmails() {
  const threads = GmailApp.search('is:unread subject:(invoice OR receipt) -label:finance');
  const financeLabel = GmailApp.getUserLabelByName('Finance')
    || GmailApp.createLabel('Finance');
  const urgentLabel = GmailApp.getUserLabelByName('Finance/Urgent')
    || GmailApp.createLabel('Finance/Urgent');

  for (const thread of threads) {
    const messages = thread.getMessages();
    const latestMessage = messages[messages.length - 1];
    const body = latestMessage.getPlainBody().toLowerCase();

    financeLabel.addToThread(thread);

    // Flag high-value invoices as urgent
    const amountMatch = body.match(/\$[\d,]+\.?\d*/);
    if (amountMatch) {
      const amount = parseFloat(amountMatch[0].replace(/[$,]/g, ''));
      if (amount > 5000) {
        urgentLabel.addToThread(thread);
        thread.markImportant();
      }
    }
  }
}javascript

This script searches for unread emails about invoices, labels them under Finance, and flags anything over $5,000 as urgent. Try doing that with Gmail's built-in filters. You can't.

💡
Tip: The GmailApp.search() method uses the same query syntax as the Gmail search bar. Prototype your queries in Gmail first, then paste them into your script.

Send Bulk Personalized Emails from Google Sheets

This is the most common request I get from clients. A google apps script send email workflow powered by spreadsheet data. Basically a DIY mail merge that costs nothing and gives you full control.

Assume your Sheet has columns: Name, Email, Company, Status, Sent At.

function sendBulkEmails() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Contacts');
  const data = sheet.getDataRange().getValues();
  const headers = data.shift(); // Remove header row

  const emailCol = headers.indexOf('Email');
  const nameCol = headers.indexOf('Name');
  const companyCol = headers.indexOf('Company');
  const statusCol = headers.indexOf('Status');
  const sentAtCol = headers.indexOf('Sent At');

  let sentCount = 0;

  for (let i = 0; i < data.length; i++) {
    const row = data[i];
    if (row[statusCol] === 'Sent') continue; // Skip already-sent rows

    const email = row[emailCol];
    const name = row[nameCol];
    const company = row[companyCol];

    const subject = `Quick question about ${company}`;
    const htmlBody = `
      <p>Hi ${name},</p>
      <p>I came across ${company} and wanted to reach out about...</p>
      <p>Best,<br>Daniel</p>
    `;

    const rowIndex = i + 2; // +2 because: header row is 1, and data array is 0-indexed

    try {
      GmailApp.sendEmail(email, subject, '', { htmlBody: htmlBody });

      // Mark as sent in the spreadsheet
      sheet.getRange(rowIndex, statusCol + 1).setValue('Sent');
      if (sentAtCol !== -1) {
        sheet.getRange(rowIndex, sentAtCol + 1).setValue(new Date());
      }

      sentCount++;
      Utilities.sleep(1000); // Rate limiting - don't hammer Gmail
    } catch (error) {
      Logger.log(`Failed to send to ${email}: ${error.message}`);
      sheet.getRange(rowIndex, statusCol + 1).setValue('Failed');
    }
  }

  Logger.log(`Sent ${sentCount} emails successfully.`);
}javascript

Here's what matters in that code:

  • Skip logic prevents double-sends if you run the function again
  • Status tracking writes back to the Sheet so you always have a record
  • `Utilities.sleep(1000)` adds a one-second delay between sends. Without it, you'll hit rate limits fast.
  • Try-catch per email means one bad address doesn't kill the entire batch
  • Explicit row index calculated from the loop counter, not indexOf(). Using indexOf() on array rows is fragile because it compares by reference, and two rows with identical data would match the wrong one.

Sounds overkill for a small list? Maybe. But when you're sending to 500 contacts and row 47 has a typo in the email, you'll be glad you built in that error handling.


Set Up Time-Based Triggers for Scheduled Sends

Apps Script triggers are how you turn a one-off function into an apps script email trigger that runs on a schedule. You can set these up in the UI or programmatically:

function createDailyTrigger() {
  // Delete existing triggers for THIS function only to avoid duplicates
  const triggers = ScriptApp.getProjectTriggers();
  for (const trigger of triggers) {
    if (trigger.getHandlerFunction() === 'morningDigest') {
      ScriptApp.deleteTrigger(trigger);
    }
  }

  ScriptApp.newTrigger('morningDigest')
    .timeBased()
    .everyDays(1)
    .atHour(8)
    .create();
}

function morningDigest() {
  const yesterday = new Date();
  yesterday.setDate(yesterday.getDate() - 1);
  const dateStr = Utilities.formatDate(yesterday, 'America/New_York', 'yyyy/MM/dd');

  const threads = GmailApp.search(`after:${dateStr} is:important`);

  if (threads.length === 0) return;

  let digest = '<h2>Yesterday\'s Important Emails</h2><ul>';
  for (const thread of threads.slice(0, 20)) {
    const subject = thread.getFirstMessageSubject();
    const from = thread.getMessages()[0].getFrom();
    digest += `<li><strong>${subject}</strong> from ${from}</li>`;
  }
  digest += '</ul>';

  GmailApp.sendEmail('daniel@diazovate.com', 'Morning Email Digest', '', {
    htmlBody: digest
  });
}javascript

A note on timezones: the .inTimezone() method doesn't exist on ClockTriggerBuilder, even though it looks like it should. If you try it, you'll get a runtime error. Instead, set your project's timezone in appsscript.json:

{
  "timeZone": "America/New_York",
  "runtimeVersion": "V8"
}json

The trigger will fire based on whatever timezone is set there. Much more reliable than trying to configure it in code.

💡
Tip: Always clean up existing triggers before creating new ones, but filter by handler function name. Don't just nuke every trigger in the project. I've seen scripts accumulate dozens of duplicate triggers because someone kept running the setup function. Each trigger fires independently. So suddenly you're sending 30 identical digest emails at 8 AM. Not a great look.

Auto-Reply to Emails Matching a Pattern

Auto-replies make sense for specific scenarios. Out-of-office for certain senders, instant acknowledgment for support requests, or automated responses to form-based inquiries.

function autoReplyToSupport() {
  const threads = GmailApp.search('is:unread to:support@yourcompany.com -label:auto-replied');
  const repliedLabel = GmailApp.getUserLabelByName('auto-replied')
    || GmailApp.createLabel('auto-replied');

  for (const thread of threads) {
    const messages = thread.getMessages();
    const latest = messages[messages.length - 1];

    // Don't auto-reply to auto-replies (prevent loops)
    const subject = latest.getSubject();
    if (subject.toLowerCase().includes('auto-reply') ||
        subject.toLowerCase().includes('out of office')) {
      continue;
    }

    const replyBody = `
      <p>Hi,</p>
      <p>Thanks for reaching out. We've received your message and someone
      from our team will get back to you within 24 hours.</p>
      <p>Your reference number: <strong>${thread.getId().slice(-8).toUpperCase()}</strong></p>
      <p>Best,<br>The Support Team</p>
    `;

    latest.reply('', {
      htmlBody: replyBody
    });

    repliedLabel.addToThread(thread);
  }
}javascript

Two critical details here. First, the loop-prevention check is not optional. Without it, two auto-replying systems can ping-pong emails back and forth forever. I've seen this happen in production. It ran up a 4,000-email thread before anyone noticed. Not fun.

Second, you might be tempted to set a from address in the reply options. Be careful with that. The from field in reply() only works if you've configured the address as a send-as alias in your Gmail settings (Settings → Accounts → Send mail as). If you haven't, Gmail silently ignores it and sends from your primary address. No error, no warning. You just find out when someone replies to the wrong address. If you need replies to come from a shared address like support@yourcompany.com, set up the alias first, then add from: 'support@yourcompany.com' to the options object.


Error Handling and Gmail Quota Limits

This is where most tutorials fall short. Gmail has hard limits, and your script will hit them if you're doing anything at scale.

Current Quota Limits (2026)

LimitFree GmailGoogle Workspace
Recipients per day5002,000
Attachment size25 MB25 MB
Script execution time6 minutes6 minutes

You can check your remaining daily quota programmatically with MailApp.getRemainingDailyQuota(). Worth calling before any bulk send operation so you don't start a batch you can't finish.

Production-Grade Error Handling

Here's the error handling pattern I use in every production gmail automation script:

function sendWithRetry(recipient, subject, body, options, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      GmailApp.sendEmail(recipient, subject, body, options);
      return { success: true, attempt: attempt };
    } catch (error) {
      const message = error.message.toLowerCase();

      if (message.includes('quota')) {
        // Quota exceeded - stop entirely, don't retry
        Logger.log(`Quota exceeded on attempt ${attempt}. Stopping.`);
        return { success: false, error: 'QUOTA_EXCEEDED', attempt: attempt };
      }

      if (message.includes('invalid') && message.includes('email')) {
        // Bad email address - don't retry
        Logger.log(`Invalid email: ${recipient}`);
        return { success: false, error: 'INVALID_EMAIL', attempt: attempt };
      }

      if (attempt < maxRetries) {
        // Transient error - wait and retry with exponential backoff
        const waitTime = Math.pow(2, attempt) * 1000;
        Logger.log(`Attempt ${attempt} failed. Retrying in ${waitTime}ms...`);
        Utilities.sleep(waitTime);
      } else {
        Logger.log(`All ${maxRetries} attempts failed for ${recipient}: ${error.message}`);
        return { success: false, error: error.message, attempt: attempt };
      }
    }
  }
}javascript

The key decisions here: quota errors stop immediately because retrying won't help. Invalid emails get skipped because they'll never work. And transient errors get exponential backoff. This is what separates a hobby script from a production one.


Security Best Practices for Email Scripts

Automating email access is powerful. It's also dangerous if done carelessly. Here's what I tell every client.

Principle of least privilege. Only request the scopes your script actually needs. If you're only sending email, gmail.send is enough. But if your script also reads, searches, or labels messages (like most of the examples in this guide), you'll need broader scopes. Here's a realistic appsscript.json for a script that does both sending and reading:

{
  "timeZone": "America/New_York",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/gmail.send",
    "https://www.googleapis.com/auth/gmail.modify"
  ]
}json

The gmail.modify scope covers reading, searching, labeling, and modifying messages. If you only listed gmail.send, your search and label operations would fail at runtime. Match your scopes to what the script actually does.

For a read-only script that never sends, gmail.readonly is the right choice. And if you're using SpreadsheetApp alongside Gmail, you'll also need spreadsheets in your scope list.

Never hardcode sensitive data. Use PropertiesService for API keys, passwords, or tokens:

// Setting a property (run once)
PropertiesService.getScriptProperties().setProperty('API_KEY', 'your-key-here');

// Reading it in your script
const apiKey = PropertiesService.getScriptProperties().getProperty('API_KEY');javascript

Validate all dynamic content. If your email body includes data from a spreadsheet or form submission, sanitize it. This is especially true for HTML emails. Unsanitized input is an injection vector.

Audit your triggers. Run ScriptApp.getProjectTriggers() periodically and remove any you don't recognize. Orphaned triggers from old versions of a script can cause unexpected behavior. I make it a habit to check mine every month or so.

💡
Warning: If your script handles personal data (customer emails, names, etc.), make sure you're compliant with GDPR, CAN-SPAM, and any applicable regulations. Apps Script doesn't exempt you from data protection law.

Full Code Repository and Next Steps

Here's a clean starter template that combines everything above into a modular, production-ready structure:

/**
 * Gmail Automation Toolkit
 * Modular functions for common Gmail automation tasks.
 */

// ---- Configuration ----
const CONFIG = {
  RATE_LIMIT_MS: 1000,
  MAX_RETRIES: 3,
  DIGEST_HOUR: 8,
  TIMEZONE: 'America/New_York',
  MAX_THREADS_PER_RUN: 50
};

// ---- Core Utilities ----
function getRemainingQuota() {
  return MailApp.getRemainingDailyQuota();
}

function safeSearch(query, maxThreads) {
  try {
    return GmailApp.search(query, 0, maxThreads || CONFIG.MAX_THREADS_PER_RUN);
  } catch (error) {
    Logger.log(`Search failed: ${error.message}`);
    return [];
  }
}

// ---- Setup ----
function setupAllTriggers() {
  const handlersToSetup = ['morningDigest', 'autoLabelEmails'];

  // Only delete triggers that belong to the functions we're about to recreate
  const existing = ScriptApp.getProjectTriggers();
  for (const trigger of existing) {
    if (handlersToSetup.includes(trigger.getHandlerFunction())) {
      ScriptApp.deleteTrigger(trigger);
    }
  }

  // Morning digest
  ScriptApp.newTrigger('morningDigest')
    .timeBased()
    .everyDays(1)
    .atHour(CONFIG.DIGEST_HOUR)
    .create();

  // Auto-label every 15 minutes
  ScriptApp.newTrigger('autoLabelEmails')
    .timeBased()
    .everyMinutes(15)
    .create();

  Logger.log('All triggers created.');
}javascript

Where to go from here:

  • Add Sheets logging. Write every send/receive event to a spreadsheet for audit trails.
  • Connect to Calendar. Auto-send prep emails before meetings.
  • Build a dashboard using Apps Script's HTML service to create a simple web UI for managing your automations.
  • Try the Gmail Advanced Service when GmailApp isn't enough. The raw API gives you full control.

The best part about automating Gmail with Google Apps Script? It compounds. Every script you write saves time every single day. Start with one automation (the morning digest or the auto-labeler), get it running reliably, then build from there.

← Back to Blog