Automating Gmail Inbox Cleanup with Google Apps Script

A practical guide to building and deploying a custom Google Apps Script to automatically categorize and archive old Gmail threads based on sender domains.

The Problem: Delayed Inbox Maintenance

If you manage a high volume of automated alerts, newsletters, billing statements, and vendor communications, your primary inbox rapidly degrades from an actionable queue into an unmanageable storage volume.

Native Gmail filters are adequate for immediate, incoming mail processing. However, they fall short when you need delayed processing—for example, keeping a bank statement or a system alert in your inbox for two weeks before automatically archiving and tagging it.

To solve this, we can leverage Google Apps Script to run a scheduled, batch-processing categorization job against older threads.

The Engine: Google Apps Script

Google Apps Script (GAS) is a cloud-based, serverless JavaScript runtime hosted directly within the Google Workspace ecosystem. Think of it as a lightweight AWS Lambda or Google Cloud Function, but with native, pre-authenticated bindings to Google APIs.

For an engineering workflow, the primary advantage is zero infrastructure overhead. You do not need to spin up a container, manage OAuth 2.0 token lifecycle for the Gmail API, or configure cron jobs on a remote Linux server. You simply write the logic, attach a built-in time-driven trigger, and Google handles the compute, execution, and authorization securely in the background.

Deployment Instructions

Deploying this automation takes about five minutes.

  1. Navigate to script.google.com in your browser.
  2. Click on New Project to create a blank workspace.
  3. Delete the default code in the editor and replace it with the script provided below.
  4. Modify the EMAIL_MAPPING constant in the configuration block to match the sender domains and labels specific to your environment.
  5. Test the execution by clicking Run in the top toolbar and selecting the categorizeOldEmails function. (Note: You will be prompted to grant permissions to your Gmail account on the first run).
  6. Set up the Cron Job: To automate this, navigate to the Triggers menu (the clock icon on the left sidebar), click Add Trigger, and configure it to run categorizeOldEmails on a Time-driven daily schedule.

The Automation Script

Below is the complete automation logic. It handles rate limits via batching (BATCH_SIZE = 100), safely processes hidden thread senders, and automatically creates missing labels before archiving.

// --- CONFIGURATION: EDIT THIS SECTION ---
const EMAIL_MAPPING = {
  '@bank.com': 'Finance/Bank General',
  'newsletters@example.com': 'Newsletters',
  'billing@company.com': 'Finance/Bills',
  'alerts@bank.com': 'Finance/Alerts',
  'updates@socialmedia.com': 'Social'
};

const AGE_THRESHOLD_WEEKS = 2; // Only process emails older than this
// ----------------------------------------

function categorizeOldEmails() {
  const delayDays = AGE_THRESHOLD_WEEKS * 7;
  const searchQuery = `in:inbox older_than:${delayDays}d`;
  
  let start = 0;
  let totalProcessed = 0;
  const BATCH_SIZE = 500;
  const MAX_PER_RUN = 1000; 

  // Pre-process mapping keys to lowercase for foolproof matching
  const normalizedMapping = {};
  for (let key in EMAIL_MAPPING) {
    normalizedMapping[key.toLowerCase()] = EMAIL_MAPPING[key];
  }

  Logger.log(`Searching for: ${searchQuery}`);

  while (totalProcessed < MAX_PER_RUN) {
    const threads = GmailApp.search(searchQuery, start, BATCH_SIZE);
    
    if (threads.length === 0) {
      Logger.log(totalProcessed === 0 ? `No emails older than ${AGE_THRESHOLD_WEEKS} weeks found.` : `Finished.`);
      break;
    }

    Logger.log(`Processing batch...`);
    let archivedThisBatch = 0;

    threads.forEach(thread => {
      // HIDDEN THREAD DATA: Check every sender in the thread to ensure we don't 
      // miss the thread just because the last message was a reply from you.
      const senders = thread.getMessages().map(msg => extractEmailAddress(msg.getFrom()));
      
      let matchedLabel = null;

      for (const [key, label] of Object.entries(normalizedMapping)) {
        const hasMatch = senders.some(sender => 
          key.startsWith('@') ? sender.endsWith(key) : sender === key
        );

        if (hasMatch) {
          matchedLabel = label;
          break; 
        }
      }
      
      if (matchedLabel) {
        applyLabelAndArchive(thread, matchedLabel);
        archivedThisBatch++;
      }
    });

    totalProcessed += threads.length;
    // Advance start only by threads we didn't archive (since archived ones leave the inbox)
    start += (threads.length - archivedThisBatch);
  }
}

/**
 * Extracts "email@addr.com" from "Name <email@addr.com>"
 */
function extractEmailAddress(fromHeader) {
  if (!fromHeader) return "";
  const match = fromHeader.match(/<([^>]+)>/);
  const email = match ? match[1] : fromHeader;
  return email.trim().toLowerCase();
}

/**
 * Creates label if missing, applies it, and moves thread out of Inbox.
 */
function applyLabelAndArchive(thread, labelName) {
  let label = GmailApp.getUserLabelByName(labelName);
  
  if (!label) {
    Logger.log(`Label [${labelName}] not found. Creating it...`);
    try {
      label = GmailApp.createLabel(labelName);
    } catch (e) {
      label = GmailApp.getUserLabelByName(labelName);
    }
  }
  
  if (label) {
    Logger.log(`Categorizing: "${thread.getFirstMessageSubject()}" -> [${labelName}]`);
    thread.addLabel(label);
    thread.moveToArchive(); 
  }
}

Maintenance Notes

  • Execution Limits: Google enforces execution time quotas for Apps Script (typically 6 minutes per run for standard accounts). The MAX_PER_RUN = 500 constant acts as a circuit breaker, ensuring the script exits gracefully before hitting a hard timeout and preventing corrupted states.
  • Label Hierarchy: If you require nested labels, utilize the standard Gmail slash notation in your mapped values (e.g., Finance/Alerts). The script will dynamically generate the nested structural path if it does not already exist.