✉️
Gmail Domain Audit & Cleanup Toolkit
✉️ Gmail Domain Audit + Smart Cleanup Toolkit

Build a safer Gmail cleanup system with audit, domain review, and controlled future blocking.

This page is a technical guide and setup portal for a tool designed to automate the mass deletion of emails within a Gmail account. It specifically addresses the limitations of the standard Gmail interface, which often makes it difficult to delete thousands of messages or clear out specific categories like "Social" or "Promotions" in a single action. The site serves as the primary documentation and configuration hub for the GMAIL-BULK-DELETE script. Its main purposes include:

1. Tool Overview and Functionality

The page explains how the script interacts with the Google Apps Script environment to identify and remove emails based on specific criteria. It is designed for users who need to clean up their storage or manage high volumes of clutter that the manual "Select All" feature in Gmail cannot handle efficiently.

2. The Setup Configuration

The specific "Setup" section provides a step-by-step walkthrough for deploying the script. This typically involves: Google Cloud Project Creation: Guiding the user through setting up a project in the Google Cloud Console to obtain necessary API permissions. Authentication: Instructions on how to authorize the script to access the user's Gmail account securely. Script Deployment: How to copy and paste the provided code into the Google Apps Script editor. Variable Customization: Defining parameters such as which labels to target, the age of emails to be deleted (e.g., older than 6 months), or specific keywords to filter.

3. Safety and Permissions

Because bulk deletion is a permanent action, the page outlines the permissions required for the script to run and provides warnings to ensure users back up important data or test the script on a small scale before executing a full cleanup. In essence, this page acts as the manual and control panel for a custom-built solution to regain control over an overflowing Gmail inbox using Google's own developer infrastructure.

Important safety warning: Never bulk-delete domains just because they look noisy. Many useful senders can come from important domains such as .gov, gmail.com, google.com, microsoft.com, paypal.com, amazon.co.uk, your bank, your employer, a school, or a medical provider. Always review each domain manually before placing an X.
Script 1 - code.gsScans Gmail, groups emails by sender domain, creates the Google Sheet, and refreshes the same sheet every 7 days if you enable weekly automation. [cite: 103, 167]
Script 2 - janitor.gsReads your manual X selections, moves old emails to Trash, and creates a future auto-delete filter for selected domains. [cite: 1, 46, 64]
Best useRun the audit first, review the Google Sheet carefully, then manually flag only the domains you truly want to clean up or block for the future.
Important ruleSelect only the options you really want. The page now clearly explains what to run first, what to run second, and what each X will do.

Before you start - recommended upgrades already included

This updated version keeps your existing information but makes the site easier to follow, easier to navigate, and better suited for someone who wants to avoid mistakes.

1. Uploaded scripts inserted

The old code blocks have been replaced with the uploaded code.gs and janitor.gs files so the page now reflects the actual files you provided.

2. Better navigation

A home button, sticky header, search box, clearer run order, and detailed sections have been added so users can move around the page more easily.

3. Clearer run sequence

The guide now explains exactly which script to run first, when to open the sheet, when to mark X, and when to run janitor.

4. Safety kept visible

Warnings about important domains, Trash recovery, and not deleting useful senders are now repeated in the places where users are most likely to need them.

Auto sheet handling

You do not need to hard-code the Google Sheet ID. The audit script creates and remembers the sheet automatically. [cite: 106, 107]

Safer recurring refresh

The audit script can refresh the same sheet every 7 days, so your domain list stays current while keeping your janitor columns intact. [cite: 167, 184]

Controlled cleanup

The janitor script acts only when you manually place an X. Nothing is removed automatically unless you explicitly flag it. [cite: 26, 27]

Google Apps Script Advanced Gmail API Google Sheets Weekly refresh Manual X approval Trash recovery safety

Easy run order - which code to run first

Follow this order exactly. This is the safest way to use the toolkit without confusion.

Step 1 - Run the audit first

In code.gs, choose startGlobalScan from the function list and click Run. This creates the spreadsheet and scans your Gmail by domain.

Step 2 - Review the Google Sheet

Open the new sheet that the audit creates. Carefully review the domains and only then place X in the columns you want to use.

Step 3 - Run janitor second

After you have selected the rows you want to process, go to janitor.gs, choose startJanitorRun, and click Run.

Simple rule: code.gs first, then review the sheet, then janitor.gs. Do not run janitor before the audit has created the sheet.

When to run weekly refresh

After the first audit works properly, run installWeeklyRefresh(). This refreshes the same audit sheet every 7 days. [cite: 167]

When to run the weekly janitor scheduler

After you fully understand the sheet and have tested the cleanup manually, run installJanitorScheduler(). This schedules weekly janitor processing. [cite: 94]

Updated script files now shown from the uploaded files

Both script panels below now use the actual uploaded files. You can copy or download them from this page, and both files should stay in the same Apps Script project.

File 1 - code.gs
Audit script - uploaded file version

/**
 * CODEGS.gs
 * Production-safe resumable Gmail audit scanner.
 * Uses Gmail Advanced Service (Gmail API), not GmailApp.
 *
 * SHEET LAYOUT:
 * A = Domain Name
 * B = Email Count
 * C = Delete Once
 * D = Always Delete
 * E = Future Junk
 * F = Example Sender
 * G = Last Received Date
 * H = First Received Date
 * I = Status
 * J = Last Action Time
 * K = Notes
 */

const CONFIG = {
  QUERY: '-in:trash -in:spam',
  PAGE_SIZE: 100,
  SAFE_RUNTIME_MS: 270000, // 4.5 minutes
  RESUME_AFTER_MS: 60000,  // 1 minute
  SHEET_NAME: 'Domain Audit',
  HEADERS: [
    'Domain Name',
    'Email Count',
    'Delete Once',
    'Always Delete',
    'Future Junk',
    'Example Sender',
    'Last Received Date',
    'First Received Date',
    'Status',
    'Last Action Time',
    'Notes'
  ]
};

/**
 * Fresh run:
 * - clears prior state
 * - creates a brand new spreadsheet
 * - starts a full scan
 */
function startGlobalScan() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(30000)) {
    Logger.log('Could not start scan because another execution is running.');
    return;
  }

  try {
    clearAuditProcessTriggers_();

    const props = PropertiesService.getScriptProperties();
    props.deleteAllProperties();

    const ss = SpreadsheetApp.create('Gmail Audit - ' + formatTimestamp_(new Date()));
    const sheet = ss.getActiveSheet();
    sheet.setName(CONFIG.SHEET_NAME);

    setupSheet_(sheet);

    props.setProperties({
      sheet_id: ss.getId(),
      page_token: '',
      status: 'RUNNING',
      started_at: new Date().toISOString(),
      mode: 'FULL_REBUILD'
    });

    Logger.log('New audit spreadsheet created: ' + ss.getUrl());
  } finally {
    lock.releaseLock();
  }

  processAuditEmails_();
}

/**
 * Refresh existing saved audit sheet:
 * - keeps same spreadsheet
 * - preserves C:K janitor/user columns
 * - rebuilds A:B and F:H from scratch
 */
function refreshExistingAudit() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(30000)) {
    Logger.log('Could not start refresh because another execution is running.');
    return;
  }

  try {
    clearAuditProcessTriggers_();

    const props = PropertiesService.getScriptProperties();
    const sheetId = props.getProperty('sheet_id');

    if (!sheetId) {
      throw new Error('No existing audit sheet is saved. Run startGlobalScan() first.');
    }

    const ss = SpreadsheetApp.openById(sheetId);
    const sheet = ss.getSheetByName(CONFIG.SHEET_NAME) || ss.getSheets()[0];

    ensureHeaders_(sheet);

    props.setProperties({
      sheet_id: ss.getId(),
      page_token: '',
      status: 'RUNNING',
      started_at: new Date().toISOString(),
      mode: 'FULL_REBUILD'
    });

    clearAuditColumnsOnly_(sheet);

    Logger.log('Refreshing existing audit spreadsheet: ' + ss.getUrl());
  } finally {
    lock.releaseLock();
  }

  processAuditEmails_();
}

/**
 * Main resumable audit processor.
 * Includes safe Gmail API retry logic to avoid "Empty response" crashes.
 */
function processAuditEmails_() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(1000)) {
    Logger.log('Another audit execution is already running. Exiting safely.');
    return;
  }

  try {
    const runStart = Date.now();
    const props = PropertiesService.getScriptProperties();

    const sheetId = props.getProperty('sheet_id');
    if (!sheetId) {
      throw new Error('No active audit sheet found.');
    }

    const ss = SpreadsheetApp.openById(sheetId);
    const sheet = ss.getSheetByName(CONFIG.SHEET_NAME) || ss.getSheets()[0];

    let pageToken = props.getProperty('page_token') || '';
    let domainMap = loadDomainMapFromSheet_(sheet);
    let skippedThreads = 0;

    Logger.log('Resuming audit. Page token: ' + (pageToken || '[START]'));

    while (true) {
      if (Date.now() - runStart > CONFIG.SAFE_RUNTIME_MS) {
        saveAuditCheckpoint_(sheet, domainMap, pageToken);
        Logger.log('Paused before timeout. Will resume in about 1 minute.');
        return;
      }

      let listResponse;
      try {
        listResponse = Gmail.Users.Threads.list('me', {
          q: CONFIG.QUERY,
          maxResults: CONFIG.PAGE_SIZE,
          pageToken: pageToken || undefined,
          fields: 'nextPageToken,threads/id'
        });
      } catch (err) {
        Logger.log('Failed to list Gmail threads: ' + safeErrorMessage_(err));
        saveAuditCheckpoint_(sheet, domainMap, pageToken);
        Logger.log('Checkpoint saved after list failure. Will retry next run.');
        return;
      }

      const threads = (listResponse && listResponse.threads) ? listResponse.threads : [];
      pageToken = (listResponse && listResponse.nextPageToken) ? listResponse.nextPageToken : '';

      if (!threads.length) {
        writeDomainMapToSheet_(sheet, domainMap);
        finalizeAuditResults_(sheet, ss);
        props.setProperties({
          sheet_id: ss.getId(),
          page_token: '',
          status: 'IDLE',
          last_completed_at: new Date().toISOString()
        });
        clearAuditProcessTriggers_();

        Logger.log('--- AUDIT 100% COMPLETE ---');
        Logger.log('Skipped threads during run: ' + skippedThreads);
        Logger.log('Results saved to: ' + ss.getUrl());
        return;
      }

      for (let i = 0; i < threads.length; i++) {
        if (Date.now() - runStart > CONFIG.SAFE_RUNTIME_MS) {
          saveAuditCheckpoint_(sheet, domainMap, pageToken);
          Logger.log('Paused during thread processing. Will resume in about 1 minute.');
          return;
        }

        const threadId = threads[i] && threads[i].id ? threads[i].id : '';
        if (!threadId) {
          Logger.log('Encountered thread with missing ID. Skipping.');
          skippedThreads++;
          continue;
        }

        const thread = getThreadWithRetry_(threadId, 3);
        if (!thread) {
          Logger.log('Skipping invalid/unavailable thread: ' + threadId);
          skippedThreads++;
          continue;
        }

        const messages = (thread && thread.messages) ? thread.messages : [];
        if (!messages.length) {
          Logger.log('Thread returned no messages. threadId=' + threadId);
          skippedThreads++;
          continue;
        }

        for (let j = 0; j < messages.length; j++) {
          const msg = messages[j];
          const internalDateMs = msg && msg.internalDate ? Number(msg.internalDate) : 0;
          const headers = (msg && msg.payload && msg.payload.headers) ? msg.payload.headers : [];
          const fromHeader = getHeaderValue_(headers, 'From');

          if (!fromHeader) continue;

          const parsed = extractDomainFromFromHeader_(fromHeader);
          if (!parsed || !parsed.domain) continue;

          const domain = parsed.domain;
          const exampleSender = parsed.exampleSender;

          if (!domainMap[domain]) {
            domainMap[domain] = {
              count: 1,
              lastDate: internalDateMs,
              firstDate: internalDateMs,
              exampleSender: exampleSender
            };
          } else {
            domainMap[domain].count++;

            if (internalDateMs > domainMap[domain].lastDate) {
              domainMap[domain].lastDate = internalDateMs;
            }

            if (internalDateMs < domainMap[domain].firstDate) {
              domainMap[domain].firstDate = internalDateMs;
            }

            if (!domainMap[domain].exampleSender && exampleSender) {
              domainMap[domain].exampleSender = exampleSender;
            }
          }
        }
      }

      props.setProperty('page_token', pageToken || '');
      writeDomainMapToSheet_(sheet, domainMap);

      Logger.log('Processed batch of ' + threads.length + ' threads. Skipped=' + skippedThreads);

      if (!pageToken) {
        writeDomainMapToSheet_(sheet, domainMap);
        finalizeAuditResults_(sheet, ss);
        props.setProperties({
          sheet_id: ss.getId(),
          page_token: '',
          status: 'IDLE',
          last_completed_at: new Date().toISOString()
        });
        clearAuditProcessTriggers_();

        Logger.log('--- AUDIT 100% COMPLETE ---');
        Logger.log('Skipped threads during run: ' + skippedThreads);
        Logger.log('Results saved to: ' + ss.getUrl());
        return;
      }
    }
  } finally {
    lock.releaseLock();
  }
}

/**
 * Safe Gmail thread fetch with retry.
 * Prevents "Empty response" from crashing the audit.
 */
function getThreadWithRetry_(threadId, maxAttempts) {
  const attempts = maxAttempts || 3;

  for (let attempt = 1; attempt <= attempts; attempt++) {
    try {
      const thread = Gmail.Users.Threads.get('me', threadId, {
        format: 'metadata',
        metadataHeaders: ['From', 'Date'],
        fields: 'messages/internalDate,messages/payload/headers'
      });

      if (!thread || !thread.messages || !thread.messages.length) {
        throw new Error('Empty response or missing messages');
      }

      return thread;

    } catch (err) {
      const message = safeErrorMessage_(err);
      const lower = message.toLowerCase();

      const isRetryable =
        lower.indexOf('empty response') !== -1 ||
        lower.indexOf('internal error') !== -1 ||
        lower.indexOf('backend error') !== -1 ||
        lower.indexOf('service unavailable') !== -1 ||
        lower.indexOf('rate limit') !== -1 ||
        lower.indexOf('quota') !== -1 ||
        lower.indexOf('timeout') !== -1;

      Logger.log(
        'Thread fetch failed. threadId=' + threadId +
        ', attempt=' + attempt + '/' + attempts +
        ', retryable=' + isRetryable +
        ', error=' + message
      );

      if (!isRetryable || attempt === attempts) {
        Logger.log('Skipping thread permanently: ' + threadId);
        return null;
      }

      Utilities.sleep(500 * attempt);
    }
  }

  return null;
}

function saveAuditCheckpoint_(sheet, domainMap, pageToken) {
  writeDomainMapToSheet_(sheet, domainMap);

  const props = PropertiesService.getScriptProperties();
  props.setProperty('page_token', pageToken || '');
  props.setProperty('status', 'RUNNING');

  createAuditResumeTrigger_();
}

function createAuditResumeTrigger_() {
  clearAuditProcessTriggers_();
  ScriptApp.newTrigger('processAuditEmails_')
    .timeBased()
    .after(CONFIG.RESUME_AFTER_MS)
    .create();
}

function clearAuditProcessTriggers_() {
  const triggers = ScriptApp.getProjectTriggers();
  for (let i = 0; i < triggers.length; i++) {
    if (triggers[i].getHandlerFunction() === 'processAuditEmails_') {
      ScriptApp.deleteTrigger(triggers[i]);
    }
  }
}

/**
 * Weekly refresh trigger.
 */
function installWeeklyRefresh() {
  clearWeeklyRefreshTriggers_();

  ScriptApp.newTrigger('refreshExistingAudit')
    .timeBased()
    .everyDays(7)
    .create();

  Logger.log('Weekly refresh trigger installed.');
}

function clearWeeklyRefreshTriggers_() {
  const triggers = ScriptApp.getProjectTriggers();
  for (let i = 0; i < triggers.length; i++) {
    if (triggers[i].getHandlerFunction() === 'refreshExistingAudit') {
      ScriptApp.deleteTrigger(triggers[i]);
    }
  }
}

function setupSheet_(sheet) {
  sheet.clear();
  sheet.getRange(1, 1, 1, CONFIG.HEADERS.length).setValues([CONFIG.HEADERS]);
  sheet.getRange(1, 1, 1, CONFIG.HEADERS.length)
    .setFontWeight('bold')
    .setBackground('#cfe2f3');
  sheet.setFrozenRows(1);
  sheet.autoResizeColumns(1, CONFIG.HEADERS.length);
}

function ensureHeaders_(sheet) {
  const current = sheet.getRange(1, 1, 1, CONFIG.HEADERS.length).getValues()[0];
  let needsReset = false;

  for (let i = 0; i < CONFIG.HEADERS.length; i++) {
    if (String(current[i] || '') !== CONFIG.HEADERS[i]) {
      needsReset = true;
      break;
    }
  }

  if (needsReset) {
    sheet.getRange(1, 1, 1, CONFIG.HEADERS.length).setValues([CONFIG.HEADERS]);
    sheet.getRange(1, 1, 1, CONFIG.HEADERS.length)
      .setFontWeight('bold')
      .setBackground('#cfe2f3');
    sheet.setFrozenRows(1);
  }
}

/**
 * Clear only audit columns, preserve janitor/user columns C:K.
 * Clears A:B and F:H data rows.
 */
function clearAuditColumnsOnly_(sheet) {
  const lastRow = sheet.getLastRow();
  if (lastRow > 1) {
    // A:B
    sheet.getRange(2, 1, lastRow - 1, 2).clearContent();
    // F:H
    sheet.getRange(2, 6, lastRow - 1, 3).clearContent();
  }
}

/**
 * Loads existing audit rows while preserving janitor columns.
 */
function loadDomainMapFromSheet_(sheet) {
  const map = Object.create(null);
  const lastRow = sheet.getLastRow();

  if (lastRow < 2) return map;

  const values = sheet.getRange(2, 1, lastRow - 1, 8).getValues();

  for (let i = 0; i < values.length; i++) {
    const row = values[i];
    const domain = String(row[0] || '').trim().toLowerCase();
    if (!domain) continue;

    map[domain] = {
      count: Number(row[1] || 0),
      exampleSender: String(row[5] || ''),
      lastDate: row[6] instanceof Date ? row[6].getTime() : 0,
      firstDate: row[7] instanceof Date ? row[7].getTime() : 0
    };
  }

  return map;
}

/**
 * Writes audit data while preserving C:K user/janitor columns by domain.
 */
function writeDomainMapToSheet_(sheet, domainMap) {
  const domains = Object.keys(domainMap).sort(function(a, b) {
    return domainMap[b].count - domainMap[a].count;
  });

  // Preserve user/janitor settings from existing sheet by domain
  const existingMeta = loadExistingJanitorMetaByDomain_(sheet);

  // Clear all data rows first
  const lastRow = sheet.getLastRow();
  if (lastRow > 1) {
    sheet.getRange(2, 1, lastRow - 1, CONFIG.HEADERS.length).clearContent();
  }

  if (!domains.length) return;

  const rows = [];
  for (let i = 0; i < domains.length; i++) {
    const domain = domains[i];
    const item = domainMap[domain];
    const meta = existingMeta[domain] || {
      deleteOnce: '',
      alwaysDelete: '',
      futureJunk: '',
      status: '',
      actionTime: '',
      notes: ''
    };

    rows.push([
      domain,
      item.count,
      meta.deleteOnce,
      meta.alwaysDelete,
      meta.futureJunk,
      item.exampleSender || '',
      item.lastDate ? new Date(item.lastDate) : '',
      item.firstDate ? new Date(item.firstDate) : '',
      meta.status,
      meta.actionTime,
      meta.notes
    ]);
  }

  sheet.getRange(2, 1, rows.length, CONFIG.HEADERS.length).setValues(rows);
  sheet.getRange(2, 7, rows.length, 2).setNumberFormat('yyyy-mm-dd hh:mm:ss');
  sheet.getRange(2, 10, rows.length, 1).setNumberFormat('yyyy-mm-dd hh:mm:ss');
  sheet.autoResizeColumns(1, CONFIG.HEADERS.length);
}

function loadExistingJanitorMetaByDomain_(sheet) {
  const meta = Object.create(null);
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return meta;

  const values = sheet.getRange(2, 1, lastRow - 1, CONFIG.HEADERS.length).getValues();

  for (let i = 0; i < values.length; i++) {
    const row = values[i];
    const domain = String(row[0] || '').trim().toLowerCase();
    if (!domain) continue;

    meta[domain] = {
      deleteOnce: row[2] || '',
      alwaysDelete: row[3] || '',
      futureJunk: row[4] || '',
      status: row[8] || '',
      actionTime: row[9] || '',
      notes: row[10] || ''
    };
  }

  return meta;
}

function finalizeAuditResults_(sheet, ss) {
  const lastRow = sheet.getLastRow();

  if (lastRow > 1) {
    sheet.getRange(2, 1, lastRow - 1, CONFIG.HEADERS.length).sort({ column: 2, ascending: false });

    if (sheet.getFilter()) {
      sheet.getFilter().remove();
    }

    sheet.getRange(1, 1, lastRow, CONFIG.HEADERS.length).createFilter();
  }

  SpreadsheetApp.flush();
  Logger.log('Final spreadsheet URL: ' + ss.getUrl());
}

function getHeaderValue_(headers, headerName) {
  if (!headers || !headers.length) return '';

  const target = String(headerName).toLowerCase();
  for (let i = 0; i < headers.length; i++) {
    const h = headers[i];
    if (h && h.name && String(h.name).toLowerCase() === target) {
      return h.value || '';
    }
  }

  return '';
}

function extractDomainFromFromHeader_(fromHeader) {
  const raw = String(fromHeader || '').trim();
  if (!raw) return null;

  let email = '';
  const angleMatch = raw.match(/<([^>]+)>/);
  if (angleMatch && angleMatch[1]) {
    email = angleMatch[1].trim();
  } else {
    email = raw;
  }

  const emailMatch =
    email.match(/[A-Z0-9._%+\-]+@([A-Z0-9.\-]+\.[A-Z]{2,})/i) ||
    raw.match(/[A-Z0-9._%+\-]+@([A-Z0-9.\-]+\.[A-Z]{2,})/i);

  if (!emailMatch || !emailMatch[1]) return null;

  return {
    domain: String(emailMatch[1]).toLowerCase(),
    exampleSender: raw
  };
}

function formatTimestamp_(date) {
  const y = date.getFullYear();
  const m = ('0' + (date.getMonth() + 1)).slice(-2);
  const d = ('0' + date.getDate()).slice(-2);
  const hh = ('0' + date.getHours()).slice(-2);
  const mm = ('0' + date.getMinutes()).slice(-2);
  const ss = ('0' + date.getSeconds()).slice(-2);
  return y + '-' + m + '-' + d + ' ' + hh + '-' + mm + '-' + ss;
}

function safeErrorMessage_(err) {
  return err && err.message ? err.message : String(err);
}
File 2 - janitor.gs
Cleanup and future auto-delete script - uploaded file version

/**
 * JANITOR.gs
 * Production-safe resumable janitor for Gmail domain cleanup.
 * Uses Gmail Advanced Service (Gmail API), not GmailApp.
 *
 * SHEET LAYOUT (must match CODEGS.gs):
 * A = Domain Name
 * B = Email Count
 * C = Delete Once
 * D = Always Delete
 * E = Future Junk
 * F = Example Sender
 * G = Last Received Date
 * H = First Received Date
 * I = Status
 * J = Last Action Time
 * K = Notes
 */

const JANITOR_CONFIG = {
  SHEET_NAME: 'Domain Audit',
  SAFE_RUNTIME_MS: 270000, // 4.5 mins
  RESUME_AFTER_MS: 60000,  // 1 min
  PAGE_SIZE: 100,

  COL_DOMAIN: 1,
  COL_EMAIL_COUNT: 2,
  COL_DELETE_ONCE: 3,
  COL_ALWAYS_DELETE: 4,
  COL_FUTURE: 5,
  COL_EXAMPLE_SENDER: 6,
  COL_LAST_RECEIVED: 7,
  COL_FIRST_RECEIVED: 8,
  COL_STATUS: 9,
  COL_ACTION_TIME: 10,
  COL_NOTES: 11,

  DATA_START_ROW: 2
};

/**
 * Starts janitor from scratch.
 * Safe to run manually.
 */
function startJanitorRun() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(30000)) {
    Logger.log('Could not start janitor because another execution is running.');
    return;
  }

  try {
    clearJanitorProcessTriggers_();

    const props = PropertiesService.getScriptProperties();
    const sheetId = props.getProperty('sheet_id');

    if (!sheetId) {
      throw new Error('No saved audit sheet found. Run startGlobalScan() first.');
    }

    const ss = SpreadsheetApp.openById(sheetId);
    const sheet = ss.getSheetByName(JANITOR_CONFIG.SHEET_NAME) || ss.getSheets()[0];

    props.setProperties({
      sheet_id: ss.getId(),
      janitor_status: 'RUNNING',
      janitor_row: String(JANITOR_CONFIG.DATA_START_ROW),
      janitor_action: '',
      janitor_page_token: '',
      janitor_domain: ''
    });

    Logger.log('Janitor started on spreadsheet: ' + ss.getUrl());
  } finally {
    lock.releaseLock();
  }

  processJanitorQueue_();
}

/**
 * Main resumable janitor queue processor.
 * Preserves original resumable engine:
 * - janitor_row
 * - janitor_action
 * - janitor_page_token
 * - janitor_domain
 */
function processJanitorQueue_() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(1000)) {
    Logger.log('Another janitor execution is already running. Exiting safely.');
    return;
  }

  try {
    const runStart = Date.now();
    const props = PropertiesService.getScriptProperties();

    const sheetId = props.getProperty('sheet_id');
    if (!sheetId) {
      throw new Error('No saved audit sheet found.');
    }

    const ss = SpreadsheetApp.openById(sheetId);
    const sheet = ss.getSheetByName(JANITOR_CONFIG.SHEET_NAME) || ss.getSheets()[0];

    let rowIndex = Number(props.getProperty('janitor_row') || JANITOR_CONFIG.DATA_START_ROW);
    let currentAction = props.getProperty('janitor_action') || '';
    let pageToken = props.getProperty('janitor_page_token') || '';
    let currentDomain = props.getProperty('janitor_domain') || '';

    const lastRow = sheet.getLastRow();

    Logger.log(
      'Resuming janitor at row ' + rowIndex +
      ', action=' + (currentAction || '[NONE]') +
      ', pageToken=' + (pageToken || '[START]') +
      ', domain=' + (currentDomain || '[NONE]')
    );

    while (rowIndex <= lastRow) {
      if (Date.now() - runStart > JANITOR_CONFIG.SAFE_RUNTIME_MS) {
        saveJanitorCheckpoint_(rowIndex, currentAction, pageToken, currentDomain);
        Logger.log('Paused before timeout. Will resume in about 1 minute.');
        return;
      }

      const row = sheet.getRange(rowIndex, 1, 1, JANITOR_CONFIG.COL_NOTES).getValues()[0];

      const domain = String(row[JANITOR_CONFIG.COL_DOMAIN - 1] || '').trim().toLowerCase();
      const deleteOnce = String(row[JANITOR_CONFIG.COL_DELETE_ONCE - 1] || '').trim().toUpperCase();
      const alwaysDelete = String(row[JANITOR_CONFIG.COL_ALWAYS_DELETE - 1] || '').trim().toUpperCase();
      const futureJunk = String(row[JANITOR_CONFIG.COL_FUTURE - 1] || '').trim().toUpperCase();

      if (!domain) {
        rowIndex++;
        currentAction = '';
        pageToken = '';
        currentDomain = '';
        continue;
      }

      // If resuming mid-row, keep using saved action/domain. Otherwise determine next action.
      if (!currentAction) {
        currentDomain = domain;

        if (deleteOnce === 'X') {
          currentAction = 'DELETE_ONCE';
        } else if (alwaysDelete === 'X') {
          currentAction = 'ALWAYS_DELETE';
        } else if (futureJunk === 'X' || hasFilterIdInNotes_(String(row[JANITOR_CONFIG.COL_NOTES - 1] || ''))) {
          currentAction = 'FUTURE_FILTER';
        } else {
          currentAction = '';
        }

        pageToken = '';
      }

      // Safety: if saved domain doesn't match current row anymore, reset row state
      if (currentAction && currentDomain !== domain) {
        Logger.log(
          'Row/domain mismatch detected. Resetting row state. row=' + rowIndex +
          ', savedDomain=' + currentDomain + ', sheetDomain=' + domain
        );
        currentAction = '';
        pageToken = '';
        currentDomain = '';
        continue;
      }

      if (currentAction === 'DELETE_ONCE') {
        setRowStatus_(sheet, rowIndex, 'RUNNING DELETE ONCE', 'Deleting existing mail for ' + domain);

        const deleteResult = deleteThreadsForDomain_(domain, pageToken, runStart);
        pageToken = deleteResult.nextPageToken || '';

        if (deleteResult.paused) {
          saveJanitorCheckpoint_(rowIndex, currentAction, pageToken, currentDomain);
          Logger.log('Paused while deleting existing mail for ' + domain + '.');
          return;
        }

        // Mark one-time delete as permanently done
        sheet.getRange(rowIndex, JANITOR_CONFIG.COL_DELETE_ONCE)
          .setValue('DONE')
          .setBackground('#d9ead3');

        setRowStatus_(
          sheet,
          rowIndex,
          'DELETE ONCE COMPLETE',
          'Moved ' + deleteResult.trashedCount + ' thread(s) to trash.'
        );

        currentAction = '';
        pageToken = '';
        currentDomain = '';
      }

      else if (currentAction === 'ALWAYS_DELETE') {
        setRowStatus_(sheet, rowIndex, 'RUNNING ALWAYS DELETE', 'Deleting recurring mail for ' + domain);

        const deleteResult = deleteThreadsForDomain_(domain, pageToken, runStart);
        pageToken = deleteResult.nextPageToken || '';

        if (deleteResult.paused) {
          saveJanitorCheckpoint_(rowIndex, currentAction, pageToken, currentDomain);
          Logger.log('Paused while recurring delete for ' + domain + '.');
          return;
        }

        // IMPORTANT: do NOT mark D as DONE. Keep X so future weekly runs start fresh.
        setRowStatus_(
          sheet,
          rowIndex,
          'ALWAYS DELETE COMPLETE',
          'Moved ' + deleteResult.trashedCount + ' thread(s) to trash.'
        );

        currentAction = '';
        pageToken = '';
        currentDomain = '';
      }

      else if (currentAction === 'FUTURE_FILTER') {
        const notes = String(sheet.getRange(rowIndex, JANITOR_CONFIG.COL_NOTES).getValue() || '');
        const shouldHaveFilter = futureJunk === 'X';

        if (shouldHaveFilter) {
          setRowStatus_(sheet, rowIndex, 'RUNNING FUTURE FILTER', 'Ensuring trash filter exists for ' + domain);

          const filterResult = ensureFutureTrashFilter_(domain, notes);

          if (filterResult.created) {
            setRowStatus_(
              sheet,
              rowIndex,
              'FUTURE FILTER ACTIVE',
              'Filter active. ' + filterResult.note
            );
          } else {
            setRowStatus_(
              sheet,
              rowIndex,
              'FUTURE FILTER ACTIVE',
              filterResult.note
            );
          }

        } else {
          setRowStatus_(sheet, rowIndex, 'REMOVING FUTURE FILTER', 'Removing trash filter for ' + domain);

          const removeResult = removeFutureTrashFilter_(notes);

          setRowStatus_(
            sheet,
            rowIndex,
            'FUTURE FILTER REMOVED',
            removeResult.note
          );
        }

        currentAction = '';
        pageToken = '';
        currentDomain = '';
      }

      rowIndex++;
    }

    props.setProperties({
      janitor_status: 'IDLE',
      janitor_row: String(JANITOR_CONFIG.DATA_START_ROW),
      janitor_action: '',
      janitor_page_token: '',
      janitor_domain: '',
      janitor_last_completed_at: new Date().toISOString()
    });

    clearJanitorProcessTriggers_();

    Logger.log('--- JANITOR 100% COMPLETE ---');
    Logger.log('Janitor completed on spreadsheet: ' + ss.getUrl());
  } finally {
    lock.releaseLock();
  }
}

/**
 * Resumable delete engine for one domain.
 * Uses Gmail native pagination and stops safely before timeout.
 */
function deleteThreadsForDomain_(domain, pageToken, runStart) {
  let trashedCount = 0;
  let nextPageToken = pageToken || '';

  while (true) {
    if (Date.now() - runStart > JANITOR_CONFIG.SAFE_RUNTIME_MS) {
      return { paused: true, nextPageToken: nextPageToken, trashedCount: trashedCount };
    }

    let listResponse;
    try {
      listResponse = Gmail.Users.Threads.list('me', {
        q: 'from:' + domain + ' -in:trash',
        maxResults: JANITOR_CONFIG.PAGE_SIZE,
        pageToken: nextPageToken || undefined,
        fields: 'nextPageToken,threads/id'
      });
    } catch (err) {
      Logger.log('Failed to list threads for delete. domain=' + domain + ', error=' + safeErrorMessage_(err));
      return { paused: true, nextPageToken: nextPageToken, trashedCount: trashedCount };
    }

    const threads = (listResponse && listResponse.threads) ? listResponse.threads : [];
    nextPageToken = (listResponse && listResponse.nextPageToken) ? listResponse.nextPageToken : '';

    if (!threads.length) {
      return { paused: false, nextPageToken: '', trashedCount: trashedCount };
    }

    for (let i = 0; i < threads.length; i++) {
      if (Date.now() - runStart > JANITOR_CONFIG.SAFE_RUNTIME_MS) {
        return { paused: true, nextPageToken: nextPageToken, trashedCount: trashedCount };
      }

      const threadId = threads[i] && threads[i].id ? threads[i].id : '';
      if (!threadId) continue;

      try {
        Gmail.Users.Threads.trash('me', threadId);
        trashedCount++;
      } catch (err) {
        Logger.log('Failed to trash thread. domain=' + domain + ', threadId=' + threadId + ', error=' + safeErrorMessage_(err));
      }
    }

    Logger.log('Delete batch processed for ' + domain + '. Threads this page=' + threads.length + ', totalTrashed=' + trashedCount);

    if (!nextPageToken) {
      return { paused: false, nextPageToken: '', trashedCount: trashedCount };
    }
  }
}

/**
 * Creates future trash filter if missing, or preserves if already present.
 */
function ensureFutureTrashFilter_(domain, notes) {
  const existingFilterId = extractFilterIdFromNotes_(notes);

  if (existingFilterId) {
    try {
      const existing = Gmail.Users.Settings.Filters.get('me', existingFilterId);
      if (existing && existing.id) {
        return {
          created: false,
          note: 'Filter already exists. filterId=' + existing.id
        };
      }
    } catch (err) {
      Logger.log('Saved filterId no longer valid for ' + domain + '. Will recreate. filterId=' + existingFilterId);
    }
  }

  const found = findExistingTrashFilterForDomain_(domain);
  if (found && found.id) {
    return {
      created: false,
      note: 'Filter already exists. filterId=' + found.id
    };
  }

  const filter = Gmail.Users.Settings.Filters.create({
    criteria: {
      from: domain
    },
    action: {
      addLabelIds: ['TRASH']
    }
  }, 'me');

  return {
    created: true,
    note: 'Created filter. filterId=' + filter.id
  };
}

/**
 * Removes future trash filter if one is referenced in notes.
 */
function removeFutureTrashFilter_(notes) {
  const filterId = extractFilterIdFromNotes_(notes);

  if (!filterId) {
    return { note: 'No saved filter ID found. Nothing to remove.' };
  }

  try {
    Gmail.Users.Settings.Filters.remove('me', filterId);
    return { note: 'Removed filter. filterId=' + filterId };
  } catch (err) {
    return { note: 'Filter could not be removed or was already missing. filterId=' + filterId };
  }
}

function findExistingTrashFilterForDomain_(domain) {
  try {
    const res = Gmail.Users.Settings.Filters.list('me');
    const filters = (res && res.filter) ? res.filter : [];

    for (let i = 0; i < filters.length; i++) {
      const f = filters[i];
      const from = f && f.criteria && f.criteria.from ? String(f.criteria.from).trim().toLowerCase() : '';
      const addLabelIds = f && f.action && f.action.addLabelIds ? f.action.addLabelIds : [];

      if (from === domain.toLowerCase() && addLabelIds.indexOf('TRASH') !== -1) {
        return f;
      }
    }
  } catch (err) {
    Logger.log('Could not inspect filters for domain=' + domain + ', error=' + safeErrorMessage_(err));
  }

  return null;
}

function hasFilterIdInNotes_(notes) {
  return /filterId=/i.test(String(notes || ''));
}

function extractFilterIdFromNotes_(notes) {
  const match = String(notes || '').match(/filterId=([A-Za-z0-9_\-]+)/i);
  return match && match[1] ? match[1] : '';
}

function saveJanitorCheckpoint_(rowIndex, action, pageToken, domain) {
  const props = PropertiesService.getScriptProperties();
  props.setProperties({
    janitor_status: 'RUNNING',
    janitor_row: String(rowIndex),
    janitor_action: action || '',
    janitor_page_token: pageToken || '',
    janitor_domain: domain || ''
  });

  createJanitorResumeTrigger_();
}

function createJanitorResumeTrigger_() {
  clearJanitorProcessTriggers_();

  ScriptApp.newTrigger('processJanitorQueue_')
    .timeBased()
    .after(JANITOR_CONFIG.RESUME_AFTER_MS)
    .create();
}

function clearJanitorProcessTriggers_() {
  const triggers = ScriptApp.getProjectTriggers();
  for (let i = 0; i < triggers.length; i++) {
    if (triggers[i].getHandlerFunction() === 'processJanitorQueue_') {
      ScriptApp.deleteTrigger(triggers[i]);
    }
  }
}

/**
 * Weekly janitor scheduler.
 * Keeps ALWAYS_DELETE eligible every week by starting fresh.
 */
function installJanitorScheduler() {
  clearJanitorSchedulerTriggers_();

  ScriptApp.newTrigger('startJanitorRun')
    .timeBased()
    .everyDays(7)
    .create();

  Logger.log('Weekly janitor scheduler installed.');
}

function clearJanitorSchedulerTriggers_() {
  const triggers = ScriptApp.getProjectTriggers();
  for (let i = 0; i < triggers.length; i++) {
    if (triggers[i].getHandlerFunction() === 'startJanitorRun') {
      ScriptApp.deleteTrigger(triggers[i]);
    }
  }
}

function setRowStatus_(sheet, rowIndex, status, notes) {
  sheet.getRange(rowIndex, JANITOR_CONFIG.COL_STATUS).setValue(status);
  sheet.getRange(rowIndex, JANITOR_CONFIG.COL_ACTION_TIME).setValue(new Date());

  if (typeof notes === 'string') {
    sheet.getRange(rowIndex, JANITOR_CONFIG.COL_NOTES).setValue(notes);
  }
}

function safeErrorMessage_(err) {
  return err && err.message ? err.message : String(err);
}

Quick setup guide

1
Log into your Google account first.
Make sure you are logged into the Gmail account that you want to audit and clean.
2
Open Google and type script google.
Click on Google Apps Script. If you are already logged in, it should take you straight to the Apps Script page.
3
Create a new blank project.
This will usually open with one starter file. You can use that first file for code.gs.
4
Add both scripts in the same project.
Keep code.gs and janitor.gs together in one Apps Script project. Do not create separate projects for them.
5
Enable the Gmail API service.
In the Apps Script editor, open Services, then add Gmail API. [cite: 1, 100]
6
Run startGlobalScan() once.
Choose startGlobalScan from the function drop-down and click Run. This creates the Google Sheet automatically and begins the first full audit. [cite: 103, 106]
7
Approve permissions.
The first run will ask for Gmail and Google Sheets access. Read the prompts and allow access so the scripts can work correctly.
8
Run installWeeklyRefresh() once.
This makes the audit refresh the same sheet every 7 days. [cite: 167]
9
Run installJanitorScheduler() once.
This schedules the janitor to run weekly, processing your automated rules. [cite: 94]
Important: You do not need to manually paste a Google Sheet ID into the scripts. The audit script creates the sheet and stores the ID automatically. [cite: 106, 107]

Very detailed step by step installation and running guide

This section is written so the user can go slowly and avoid mistakes.

Part 1 - Log in and open Google Apps Script
1
Log into your Google account.
Use the same Google account that owns the Gmail mailbox you want to clean. If you are logged into the wrong account, the script may scan the wrong Gmail inbox.
2
Open Google search.
Type script google in the search box and open Google Apps Script.
3
Create a blank project.
You will normally see a new project with a default script file ready for editing.
Part 2 - Add both uploaded scripts in one place
4
Use the first file for code.gs.
Delete any starter code already in the first file, then paste the content of code.gs.
5
Create the second file.
Click the plus button to add a new script file and name it janitor.gs.
6
Paste the janitor code into the second file.
Now both scripts are inside the same Apps Script project, which is exactly how this toolkit should be set up.
Keep both files in the same project because janitor.gs depends on the sheet created and remembered by code.gs.
Part 3 - Enable services and run the first script
7
Add Gmail API from Services.
Inside Apps Script, find Services, add Gmail API, and confirm.
8
Choose the correct function.
At the top of Apps Script, there is a function selection drop-down. Select startGlobalScan.
9
Click Run.
This is the first code that must be run. It creates the Google Sheet and starts the Gmail domain audit.
10
Approve permissions if asked.
Google may show warning or permission screens. Continue carefully and allow the script to access what it needs so it can scan Gmail and create the sheet.
Part 4 - Run the weekly refresh option
11
After the first audit is working, select installWeeklyRefresh.
Use the same function selection box at the top of Apps Script.
12
Click Run.
This sets the audit to refresh the same Google Sheet every 7 days.
Part 5 - Open and understand the Google Sheet
13
Go to Google Sheets and open the sheet created by the audit.
The sheet is created automatically by the first script. The audit normally names it Gmail Audit - [Timestamp].
14
Review the sender domains carefully.
You will see domains, email counts, example senders, and dates. Take your time. This part is very important.
15
Select what you want to delete.
Place X in the correct columns for the rows you want to process.
Important selection rule: remember to select Delete Once or Always Delete or Future Junk only when you truly want that action. Based on your instruction, at one time you should only work carefully with the intended choices and not mark rows blindly. Anything selected can be deleted or filtered.
If you put X in Delete Once and Future Junk, the toolkit can clear existing messages and also create future filtering for that domain. If you put X in Always Delete, weekly janitor runs can keep cleaning that domain if the scheduler is active.
Part 6 - Run janitor after making your selections
16
Go back to Apps Script.
Do not edit the code unless you know exactly what you are doing.
17
Select startJanitorRun from the function list.
This is the correct janitor function to run manually after you have made your X selections in the sheet.
18
Click Run.
The janitor now reads the sheet and processes whatever you selected.
19
Return to the Google Sheet and check columns I, J, and K.
These columns show the status, action time, and notes so you can see what happened.
Part 7 - Weekly janitor automation
20
Only after manual testing, select installJanitorScheduler.
This sets a weekly janitor trigger so your selected automated rules can continue running.
21
Click Run once.
You only need to install the scheduler once unless you later remove or change it.
Part 8 - Safety, recovery, and what not to delete
22
Be careful with important domains.
Do not delete emails sent from .gov, gmail.com, or any domain from which you receive important emails unless you are completely sure.
23
If you selected the wrong domain by mistake, check Trash or Bin.
Messages moved by janitor go to Trash or Bin, so you may be able to retrieve them if you acted quickly and they have not been permanently removed.
24
Remember that what you select will be processed.
If you mark a row with X and then run janitor, the script will act on that selection. Always double-check before running it.

What the Google Sheet looks like and how to use it

ColNameMeaning
ADomain NameThe sender domain, such as example.com. [cite: 101]
BEmail CountHow many emails in your mailbox came from that domain. [cite: 102]
CDelete OncePut X here to move current emails to Trash one time. [cite: 101, 33]
DAlways DeletePut X here for recurring weekly cleanup of this domain. [cite: 101, 39]
EFuture JunkPut X here to create a permanent Gmail filter. [cite: 101, 46]
FExample SenderA sample From header to help you recognize the sender. [cite: 101]
GLast Received DateThe newest email date found for that domain. [cite: 101]
HFirst Received DateThe oldest email date found for that domain. [cite: 101]
IStatusThe script writes progress and results here. [cite: 101]
JLast Action TimeShows when the janitor last acted on that row. [cite: 101]
KNotesExtra details such as filter IDs and trash counts. [cite: 101]

How to use the X columns

Delete Once is for current cleanup now. Always Delete is for repeated cleanup. Future Junk is for future incoming mail filtering.

What to check after janitor runs

Look at Status, Last Action Time, and Notes so you can confirm what happened.

How to clean up: place an X in Column C, D, or E as needed, then run startJanitorRun(). [cite: 4, 26, 27]

Safe use checklist and domains you should review very carefully

Do not bulk-flag blindly

  • Review the Example Sender before placing an X.
  • Sort by Email Count and inspect high-volume senders manually.
  • Keep business, work, finance, education, and medical providers under extra review.
  • Double-check before running janitor.

Examples to review carefully first

  • .gov domains
  • gmail.com and google.com
  • amazon.co.uk, paypal.com, banks, utility providers, schools, and employers
  • Any domain you actually receive important email from
Remember: the system does not decide what is safe to delete. The user must make that decision manually. [cite: 26, 27]

What each script does

code.gs - Audit script

Creates the main sheet, scans Gmail, and can refresh the data every 7 days. [cite: 106, 122, 167]

Run first Creates the sheet Weekly refresh ready

janitor.gs - Cleanup script

Reads your X selections and handles one-time deletions, recurring cleanups, or filter creation. [cite: 1, 33, 39, 46]

Run after audit Acts only on X rows Updates I-J-K

Quick FAQ

Where is the Google Sheet?

Created automatically when you run startGlobalScan(). The name will be Gmail Audit - [Timestamp]. [cite: 106]

What is the difference between Delete Once and Always Delete?

Delete Once clears the inbox now and then clears the one-time selection. Always Delete is for repeated cleanup when the weekly janitor scheduler is active. [cite: 37, 43]

Does Future Junk create real filters?

Yes. It creates a native Gmail filter that automatically moves new incoming mail from that domain to the Trash. [cite: 46, 75]

What if I selected the wrong domain?

Check Gmail Trash or Bin as quickly as possible and restore the emails if they are still there. This is why you should review every domain carefully before running janitor.

Can both scripts stay in one project?

Yes. They should stay in the same Apps Script project.

Copied to clipboard