How to Build a Smarter Gmail Auto-Reply with Google Apps Script
Content

How to Build a Smarter Gmail Auto-Reply with Google Apps Script

If you need to create an intelligent auto-reply with specific conditions—like restricting replies to certain domains, or controlling when the auto-responder fires—this script will do the job perfectly.

We’ve all seen the classic vacation auto-replies, and we’ve all been annoyed when they hit our inbox multiple times. But what if you actually want something smarter? Maybe you have a lead capture inbox, or you want to send different replies depending on who’s reaching out. The problem with Gmail’s native vacation responder is that it’s too broad and it will reply to everyone, sometimes repeatedly, and with no real conditions.

That’s why I built a custom Gmail auto-responder using Google Apps Script. It lets you define specific rules, like replying only once per sender, filtering by domain, or limiting replies to certain days or times. It’s simple, lightweight, and can be a real upgrade over the standard Gmail auto-reply or even some paid add-ons.

And once you start thinking about it, the real value shows up in everyday situations where a smarter auto-reply saves you time and makes a better impression. There are situations where an email address receives leads or inquiries automatically, and you want to send an auto-reply to the user. Maybe you want to let them know you’ll follow up within a certain amount of time, provide additional information, or even share a calendar link to schedule a call. In all of these cases, you want that inbox to perform like a lead capture tool and set the right expectations with whoever sent the email.

It’s not just about sending an auto-reply—it’s about doing it under the right conditions. You don’t want to reply if you’ve already read the email, and most importantly, you only want to reply the first time someone emails you. Otherwise, it can get annoying very fast.

Some common use cases I see for this:

  • You have a lead capture page with a form that sends you an email on submit, and you want to automatically reply with more information.
  • You manage multiple addresses, such as an info@ or contact@ inbox alongside your personal email. You’d want the auto-reply to go out from info@, while still being able to follow up personally from your main address.
  • You want the auto-responder to deliver specific materials to the sender, like a brochure, case study, or e-book, to help them with next steps.

Of course, there are add-ons that can do this, and many CRMs can handle auto-replies through workflows. HubSpot, for example, has excellent triggers that make this easy to set up. But things fall short if someone bypasses your web form and emails you directly at info@ or contact@. That’s where this script is especially useful. It’s also perfect for small business owners who don’t yet use a CRM but still want a good process for handling incoming leads.

Gmail Auto-Reply Features:

  1. Only one auto-reply per unique sender
  2. Uses a Google Sheet to store all sender email addresses
  3. Lets you block or allow specific sender domains
  4. Can be set to run only for certain sender domains
  5. Runs on specific intervals you define (time-based triggers)
  6. Tags processed messages automatically with a Gmail label
  7. Can be restricted to start only after a specific date and time
  8. Can be restricted to certain days of the week or hours of the day (e.g. off-hours or weekends)
  9. Lets you choose which Gmail categories to search (Inbox, Primary, Social, Promotions, etc.)
  10. Can limit replies to Inbox + Unread messages only
  11. Supports caching sender data to speed up Google Sheets lookups
  12. Uses two functions—one to set up your Google Sheet, and another to process your Inbox (no manual setup required)

If you’ve ever wanted Gmail to behave a little more like a CRM—or just wanted more control over how and when auto-replies are sent—this script is a great solution. You don’t need to install third-party apps, and you don’t need to pay for an add-on. With just a few lines of Apps Script, you can automate smarter replies, save yourself time, and give a better experience to the people reaching out to you.

The best part? You can customize it endlessly. Want to send different templates depending on the sender’s domain? Want to only reply outside of business hours? Want to attach a calendar link or brochure? It’s all possible.

I’ll keep iterating on this, but even in its simplest form, it’s been a huge help for managing inbound leads without cluttering up my workflow.

To get started, go to script.google.com, create a new project, paste the code into your Code.gs, and set a time-based trigger to run the processInbox function.

/***** First-Time Auto-Reply for Gmail (Google Apps Script) *****
 * Behavior:
 * - Auto-reply exactly once per unique sender.
 * - Stores sender emails in a Google Sheet.
 * - Skips lists, auto-submitted mail, and no-reply addresses.
 * - Adds a "FTAR_PROCESSED" label to handled messages.
 ****************************************************************/

const CONFIG = {
  // Your display name to use in replies (optional)
  SENDER_NAME: 'Your Name',

  // Only auto-reply to people outside your domain?
  // Set to "" to ignore domain filtering. Example: "example.com"
  EXCLUDE_OWN_DOMAIN: 'yourdomain.com',

  ALLOWED_DOMAIN: '@allowthisdomain.com', // set '' to disable domain gating

// Only consider messages strictly after this instant (overrides last-run if later)
  MANUAL_START_ISO: '2025-10-01T22:50:00-06:00', // Oct 1, 2025 10:50 PM MDT (America/Denver)
  LOOKBACK_MINUTES: 120,                         // (optional) search window safety net


  // Optional: Only auto-reply during these hours (24h, local time). Set to null to ignore.
  BUSINESS_HOURS: null, // 9 → 18 local time
  WEEKDAYS_ONLY: false, // skip weekends

  TEST_MODE: true, // set to false when you want to actually send replies

  // The reply content (plain text and/or HTML). HTML will be used if provided.
  REPLY_SUBJECT_PREFIX: 'Thanks for reaching out — ',
  REPLY_PLAIN: 'Hi {{firstName}},\n\nThanks for your message! I received it and will get back to you shortly.\n\nBest,\n{{senderName}}',
  REPLY_HTML: `
    <div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
      <p>Hi {{firstName}},</p>
      <p>Thanks for your message! I received it and will get back to you shortly.</p>
      <p>Best,<br>{{senderName}}</p>
    </div>
  `,

  // Gmail search query to find candidates (keep it narrow)
  SEARCH_QUERY: 'in:inbox is:unread -category:social -category:promotions',

  // Label added to messages we already considered (avoid double-processing)
  PROCESSED_LABEL: 'FTAR_PROCESSED',

  // Spreadsheet to store senders (auto-created by setup())
  SHEET_NAME: 'FirstTimeAutoReply_Senders',
  SHEET_HEADER: ['email', 'first_seen_iso', 'name_sample', 'notes'],

  // Safety: skip if message size is too large (rare edge cases)
  MAX_MESSAGES_PER_RUN: 50
};

// Global cache for sheet and label
let _sheet, _label, _seenSet;


/** One-time setup: creates the sheet + label. Run manually once. */
function setup() {
  const ss = SpreadsheetApp.create(CONFIG.SHEET_NAME);
  const sheet = ss.getActiveSheet();
  sheet.clear();
  sheet.appendRow(CONFIG.SHEET_HEADER);

  getOrCreateLabel_(CONFIG.PROCESSED_LABEL); // create Gmail label

  Logger.log('Setup complete.');
  Logger.log(`Sender registry sheet: ${ss.getUrl()}`);
}

/** Main worker: scans inbox and auto-replies to first-time senders. */
function processInbox() {
  init_();

  Logger.log("Running auto-reply check at " + new Date().toISOString());

  // --- Cursors ---
  const lastRunTs = getLastRunTs_();     // epoch ms (0 if first run)
  const manualStartTs = getManualStartTs_(); // epoch ms (0 if not set)

  // Effective start = the stricter of the two
  const effectiveStartTs = Math.max(lastRunTs, manualStartTs);
  Logger.log(`DEBUG: cursors -> lastRunTs=${lastRunTs || 'none'}, manualStartTs=${manualStartTs || 'none'}, effective=${effectiveStartTs || 'none'}`);

  // --- Build bounded query using a short lookback window (safety net) ---
  const boundedQuery = (CONFIG.LOOKBACK_MINUTES && CONFIG.LOOKBACK_MINUTES > 0)
    ? CONFIG.SEARCH_QUERY + ` newer_than:${CONFIG.LOOKBACK_MINUTES}m`
    : CONFIG.SEARCH_QUERY;

  // --- Fetch candidate threads within the window ---
  const threadsAll = GmailApp.search(boundedQuery, 0, CONFIG.MAX_MESSAGES_PER_RUN);
  Logger.log(`DEBUG: threads found = ${threadsAll.length} for query: ${boundedQuery}`);

  // --- Keep only threads with activity strictly after effectiveStartTs ---
  const threads = threadsAll.filter(t => {
    const d = t.getLastMessageDate();
    return d && d.getTime() > effectiveStartTs;
  });
  Logger.log(`DEBUG: threads after effective-start filter = ${threads.length}`);

  if (!threads.length) {
    // Advance last-run so we move forward next time
    setLastRunTs_(Date.now());
    return;
  }

  for (const thread of threads) {
    try {
      // Skip already processed threads
      if (threadHasLabel_(thread, _label)) {
        Logger.log('DEBUG: skip = already labeled');
        continue;
      }

      const messages = thread.getMessages();
      const last = messages[messages.length - 1];
      if (!last || last.isInTrash() || last.isDraft()) {
        Logger.log('DEBUG: skip = trash/draft');
        labelThread_(thread);
        continue;
      }

      // Use the most recent unread message as the trigger point
      const msg = last;
      if (!msg.isUnread()) {
        Logger.log('DEBUG: skip = not unread');
        labelThread_(thread);
        continue;
      }

      // Extract sender info
      const fromStr = msg.getFrom(); // "John Doe <john@example.com>"
      const senderEmail = extractEmail_(fromStr);
      if (!senderEmail) {
        Logger.log('DEBUG: skip = no senderEmail');
        labelThread_(thread);
        continue;
      }
      const senderName = extractName_(fromStr);

      Logger.log(`DEBUG: candidate sender=${senderEmail}, subject="${thread.getFirstMessageSubject()}"`);

      // ✅ Restrict to specific domain (case-insensitive) if configured
      if (CONFIG.ALLOWED_DOMAIN && !senderEmail.toLowerCase().endsWith(CONFIG.ALLOWED_DOMAIN.toLowerCase())) {
        Logger.log(`DEBUG: skip = not in allowed domain (${CONFIG.ALLOWED_DOMAIN})`);
        // labelThread_(thread); // optional during testing
        continue;
      }

      // Heuristics to skip bots/lists/auto mail
      if (shouldSkip_(msg, senderEmail)) {
        Logger.log('DEBUG: skip = shouldSkip_() (bot/list/auto)');
        labelThread_(thread);
        continue;
      }

      // Optional: skip own domain
      if (CONFIG.EXCLUDE_OWN_DOMAIN && isSameDomain_(senderEmail, CONFIG.EXCLUDE_OWN_DOMAIN)) {
        Logger.log(`DEBUG: skip = same domain (${CONFIG.EXCLUDE_OWN_DOMAIN})`);
        labelThread_(thread);
        continue;
      }

      // Optional: business hours gating
      if (!withinSchedule_()) {
        Logger.log('DEBUG: skip = outside schedule');
        // labelThread_(thread); // optional during testing
        continue;
      }

      // Only reply once EVER per sender
      if (seenBefore_(senderEmail)) {
        Logger.log('DEBUG: skip = seen before');
        labelThread_(thread);
        continue;
      }

      Logger.log('DEBUG: passed all checks; ready to reply (or TEST_MODE)');

      // Build reply
      const firstName = (senderName || '').split(' ')[0] || 'there';
      const htmlBody = template_(CONFIG.REPLY_HTML, { firstName, senderName: CONFIG.SENDER_NAME });

      if (!CONFIG.TEST_MODE) {
        // Send as reply in-thread
        msg.reply('', {
          htmlBody,
          name: CONFIG.SENDER_NAME,
          noReply: false
        });

        recordSender_(senderEmail, senderName);
        labelThread_(thread);
        Logger.log(`DEBUG: sent reply to ${senderEmail}`);
      } else {
        Logger.log(`TEST_MODE: Would reply to ${senderEmail} (firstName=${firstName})`);
        // recordSender_(senderEmail, senderName); // optional during testing
        // labelThread_(thread);                   // optional during testing
      }

    } catch (e) {
      try { labelThread_(thread); } catch (_) {}
      console.error('Error processing thread:', e);
    }
  }

  // Advance last-run AFTER processing all candidates
  setLastRunTs_(Date.now());
}


/* ========================= Helpers ========================= */

function init_() {
  if (!_label) _label = getOrCreateLabel_(CONFIG.PROCESSED_LABEL);
  if (!_sheet) _sheet = getOrCreateSheet_(CONFIG.SHEET_NAME, CONFIG.SHEET_HEADER);

  // Build cache once per execution
  if (!_seenSet) {
    const values = _sheet.getDataRange().getValues(); // includes header
    _seenSet = new Set(
      values.slice(1).map(row => (row[0] || '').toString().toLowerCase()).filter(Boolean)
    );
  }
}

function getOrCreateLabel_(name) {
  return GmailApp.getUserLabelByName(name) || GmailApp.createLabel(name);
}

function getOrCreateSheet_(name, header) {
  const files = DriveApp.getFilesByName(name);
  let ss;
  if (files.hasNext()) {
    ss = SpreadsheetApp.open(files.next());
  } else {
    ss = SpreadsheetApp.create(name);
  }
  const sheet = ss.getActiveSheet();
  const firstRow = sheet.getRange(1, 1, 1, header.length).getValues()[0];
  const headerMismatch = firstRow.join('') !== header.join('');
  if (headerMismatch) {
    sheet.clear();
    sheet.appendRow(header);
  }
  return sheet;
}

function threadHasLabel_(thread, label) {
  return thread.getLabels().some(l => l.getName() === label.getName());
}

function labelThread_(thread) {
  thread.addLabel(_label);
}

function extractEmail_(fromStr) {
  if (!fromStr) return '';
  const m = fromStr.match(/<([^>]+)>/);
  if (m && m[1]) return m[1].trim().toLowerCase();
  // If no angle brackets, the whole string might be the email
  if (fromStr.includes('@')) return fromStr.trim().toLowerCase();
  return '';
}

function extractName_(fromStr) {
  if (!fromStr) return '';
  const m = fromStr.match(/^([^<"]+)\s*</);
  if (m && m[1]) return m[1].trim().replace(/"/g, '');
  // If just an email, return prefix before @
  if (fromStr.includes('@')) return fromStr.split('@')[0];
  return '';
}

function shouldSkip_(msg, senderEmail) {
  const fromLower = (senderEmail || '').toLowerCase();
  if (fromLower.includes('no-reply') || fromLower.includes('noreply') || fromLower.startsWith('donotreply')) return true;

  // Skip lists / bulk
  const precedence = safeHeader_(msg, 'Precedence'); // e.g., 'bulk', 'list', 'auto_reply'
  if (/(bulk|list|auto_reply)/i.test(precedence)) return true;

  const listId = safeHeader_(msg, 'List-Id');
  if (listId) return true;

  // Skip auto-submitted messages
  const autoSubmitted = safeHeader_(msg, 'Auto-Submitted'); // e.g., 'auto-generated'
  if (autoSubmitted && !/no/i.test(autoSubmitted)) return true;

  // Skip if from yourself
  const me = Session.getActiveUser().getEmail().toLowerCase();
  if (fromLower === me) return true;

  return false;
}

function safeHeader_(msg, name) {
  try {
    return msg.getHeader(name) || '';
  } catch (_) {
    // getHeader is supported on GmailMessage in Apps Script; if not available, ignore
    return '';
  }
}

function isSameDomain_(email, domain) {
  const m = email.split('@')[1] || '';
  return m.toLowerCase() === domain.toLowerCase();
}

function withinSchedule_() {
  const now = new Date();
  const day = now.getDay(); // 0=Sun..6=Sat
  const hour = now.getHours();

  if (CONFIG.WEEKDAYS_ONLY && (day === 0 || day === 6)) return false;
  if (CONFIG.BUSINESS_HOURS) {
    const { start, end } = CONFIG.BUSINESS_HOURS;
    if (hour < start || hour >= end) return false;
  }
  return true;
}

function seenBefore_(email) {
  return _seenSet.has((email || '').toLowerCase());
}


function recordSender_(email, name) {
  const iso = new Date().toISOString();
  _sheet.appendRow([email.toLowerCase(), iso, name || '', 'auto-replied']);
  if (_seenSet) _seenSet.add(email.toLowerCase()); // keep cache hot
}

function getLastRunTs_() {
  const v = PropertiesService.getScriptProperties().getProperty('FTAR_LAST_RUN_TS');
  return v ? Number(v) : 0;
}
function setLastRunTs_(ts) {
  PropertiesService.getScriptProperties().setProperty('FTAR_LAST_RUN_TS', String(ts));
}

function getManualStartTs_() {
  if (!CONFIG.MANUAL_START_ISO) return 0;
  // Rely on explicit offset in the ISO string (e.g., ...-06:00)
  const d = new Date(CONFIG.MANUAL_START_ISO);
  return isNaN(d.getTime()) ? 0 : d.getTime();
}

// RUN THIS ONCE to clear the saved "last run" timestamp
function resetCursor() {
  PropertiesService.getScriptProperties().deleteProperty('FTAR_LAST_RUN_TS');
  Logger.log('FTAR_LAST_RUN_TS cleared');
}

// Optional: check the current value
function showCursor() {
  const v = PropertiesService.getScriptProperties().getProperty('FTAR_LAST_RUN_TS');
  Logger.log('FTAR_LAST_RUN_TS = ' + (v || '(none)'));
}


/* ========== Utilities ========== */

/** Simple token templating: {{firstName}} → value */
function template_(str, map) {
  return str.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k) => (map[k] ?? ''));
}

Latest Articles

How to Build a Smarter Gmail Auto-Reply with Google Apps Script

How to Build a Smarter Gmail Auto-Reply with Google Apps Script

If you need to create an intelligent auto-reply with specific conditions—like restricting replies to certain domains, or controlling when the auto-responder fires—this script will do the job perfectly.

October 2, 2025
Increase Your Revenue with Renewal Discounts

Increase Your Revenue with Renewal Discounts

Keeping customers with recurring subscriptions or product renewals is quite critical for sustainable revenue growth. One tactic is to offer targeted pricing discounts that can nudge customers into renewing their products—without sacrificing your overall revenue.

October 2, 2025
The Future of SEO: Optimizing Content for AI-Powered Search Engines

The Future of SEO: Optimizing Content for AI-Powered Search Engines

AI-driven search engines like ChatGPT and Google Gemini are changing how content ranks in 2025. To optimize for AI-powered search, content must be conversational, structured in a Q&A format, and utilize long-tail keywords. Implementing FAQ schema and structured data improves discoverability, while authority and freshness enhance ranking. As AI search evolves, businesses must adapt their content strategies to stay competitive.

March 20, 2025
How to Sync Multiple Google Calendars for Free Using Google Apps Script

How to Sync Multiple Google Calendars for Free Using Google Apps Script

Managing multiple Google Calendars can be a hassle, especially when juggling clients, businesses, or teams that need visibility into your availability. While paid tools exist, there’s a free and easy way to sync your calendars automatically using Google Apps Script. In this guide, I'll walk you through setting up a two-way calendar sync that updates events in real time—without the need for third-party software.

March 4, 2025