Save Gmail Attachments to Drive and Log Them in Sheets

This Google Apps Script scans your Gmail for messages with attachments, saves each real file to a Drive folder, and logs a row (date, sender, subject, filename, Drive link) to a Google Sheet — then labels the thread so it is never processed twice. It skips inline signature images and Google Docs/Sheets attachments automatically, which is where most versions of this script go wrong.

What this script actually does

Answer capsule: a time-driven trigger runs a search against your inbox, walks every matching thread’s messages, saves each qualifying attachment to a named Drive folder with automatic duplicate-name handling, and writes one log row per saved file to a Sheet. Threads are labeled after processing so re-runs only look at new mail.

This fits recurring situations like collecting invoice PDFs from a billing inbox, archiving signed contracts, or pulling submitted files out of a shared support alias — anywhere attachments arrive by email but need to live in Drive with a searchable record in a Sheet.

The verified script

Paste this into Extensions > Apps Script from any Google Sheet (it opens the log sheet by ID, so it does not need to be bound to a specific file — see the setup steps below for why that matters).

/**
 * Save Gmail Attachments to Drive + Log to Sheet
 * ------------------------------------------------
 * Scans Gmail for messages matching a search query, saves each real
 * attachment to a Drive folder, logs one row per file to a Sheet, and
 * labels each processed thread so it's never re-scanned on the next run.
 *
 * Setup: fill in CONFIG below, then run installTrigger() once from the
 * Apps Script editor (Run > installTrigger). See the article for full
 * step-by-step setup and the required OAuth scopes.
 */

const CONFIG = {
  // Base Gmail search — the script automatically appends
  // "-label:<GMAIL_LABEL_NAME>" so processed threads are skipped next run.
  GMAIL_SEARCH_QUERY: 'has:attachment newer_than:7d',
  GMAIL_LABEL_NAME: 'Attachments-Saved',
  DRIVE_FOLDER_ID: 'PASTE_YOUR_DRIVE_FOLDER_ID_HERE',
  LOG_SPREADSHEET_ID: 'PASTE_YOUR_LOG_SPREADSHEET_ID_HERE', // the file ID from the sheet's URL
  SHEET_NAME: 'Attachment Log',
  MIN_ATTACHMENT_SIZE_BYTES: 15 * 1024, // 15 KB — filters out stray signature/tracking images
  EXCLUDED_CONTENT_TYPES: ['text/calendar'], // skip .ics meeting invites by default
  ALLOWED_EXTENSIONS: null, // e.g. ['pdf', 'xlsx'] to only save specific file types, or null for all
  MAX_THREADS_PER_RUN: 50,
};

// ================= Pure helpers (unit-tested; no Google service calls) =================

function buildSearchQuery(config) {
  const base = (config.GMAIL_SEARCH_QUERY || 'has:attachment').trim();
  return base + ' -label:' + config.GMAIL_LABEL_NAME;
}

function getFileExtension(filename) {
  const idx = filename.lastIndexOf('.');
  if (idx === -1 || idx === filename.length - 1) return '';
  return filename.slice(idx + 1).toLowerCase();
}

function shouldSaveAttachment(meta, config) {
  if (meta.isGoogleType) {
    return { save: false, reason: 'google-workspace-file (no real bytes to copy)' };
  }
  if (typeof meta.size === 'number' && meta.size < config.MIN_ATTACHMENT_SIZE_BYTES) {
    return { save: false, reason: 'below-min-size (likely inline logo/signature image)' };
  }
  if (config.EXCLUDED_CONTENT_TYPES && config.EXCLUDED_CONTENT_TYPES.includes(meta.contentType)) {
    return { save: false, reason: 'excluded-content-type:' + meta.contentType };
  }
  if (config.ALLOWED_EXTENSIONS && config.ALLOWED_EXTENSIONS.length > 0) {
    const ext = getFileExtension(meta.name);
    if (!config.ALLOWED_EXTENSIONS.map(e => e.toLowerCase()).includes(ext)) {
      return { save: false, reason: 'extension-not-allowed:' + ext };
    }
  }
  return { save: true, reason: null };
}

function buildUniqueDriveFileName(originalName, existingNames) {
  if (!existingNames.has(originalName)) {
    return originalName;
  }
  const dotIdx = originalName.lastIndexOf('.');
  const base = dotIdx === -1 ? originalName : originalName.slice(0, dotIdx);
  const ext = dotIdx === -1 ? '' : originalName.slice(dotIdx);
  let n = 2;
  let candidate;
  do {
    candidate = base + ' (' + n + ')' + ext;
    n++;
  } while (existingNames.has(candidate));
  return candidate;
}

function buildLogRow(ctx) {
  return [
    ctx.processedAt.toISOString(),
    ctx.emailDate.toISOString(),
    ctx.from,
    ctx.subject,
    ctx.fileName,
    ctx.sizeBytes,
    ctx.driveUrl,
  ];
}

function processThreadAttachments(thread, config, ops) {
  const results = { saved: [], skipped: [] };
  const existingNames = ops.listExistingNames(config.DRIVE_FOLDER_ID);
  const messages = thread.getMessages();
  for (let mi = 0; mi < messages.length; mi++) {
    const message = messages[mi];
    const attachments = message.getAttachments({
      includeAttachments: true,
      includeInlineImages: false, // without this, inline signature/logo images come back as "attachments" too
    });
    for (let ai = 0; ai < attachments.length; ai++) {
      const att = attachments[ai];
      const meta = {
        name: att.getName(),
        contentType: att.getContentType(),
        size: att.getSize(),
        isGoogleType: att.isGoogleType(),
      };
      const verdict = shouldSaveAttachment(meta, config);
      if (!verdict.save) {
        results.skipped.push({ name: meta.name, reason: verdict.reason });
        continue;
      }
      const uniqueName = buildUniqueDriveFileName(meta.name, existingNames);
      const saved = ops.saveFile(config.DRIVE_FOLDER_ID, att.copyBlob(), uniqueName);
      existingNames.add(uniqueName);
      const row = buildLogRow({
        processedAt: ops.now(),
        emailDate: message.getDate(),
        from: message.getFrom(),
        subject: message.getSubject(),
        fileName: uniqueName,
        sizeBytes: meta.size,
        driveUrl: saved.url,
      });
      ops.appendRow(config.SHEET_NAME, row);
      results.saved.push({ name: uniqueName, url: saved.url });
    }
  }
  return results;
}

// ================= Google-service wiring (real ops — not unit-tested, mocked instead) =================

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

function getOrCreateSheet_(spreadsheetId, sheetName) {
  // openById (not getActiveSpreadsheet) — this script runs on a time-driven
  // trigger with no active spreadsheet, and standalone scripts have no bound
  // file, so getActiveSpreadsheet() returns null and getSheetByName() on it
  // throws "Cannot read properties of null."
  const ss = SpreadsheetApp.openById(spreadsheetId);
  let sheet = ss.getSheetByName(sheetName);
  if (!sheet) {
    sheet = ss.insertSheet(sheetName);
    sheet.appendRow(['Processed At', 'Email Date', 'From', 'Subject', 'File Name', 'Size (bytes)', 'Drive URL']);
  }
  return sheet;
}

function buildRealOps_(spreadsheetId, sheetName) {
  const sheet = getOrCreateSheet_(spreadsheetId, sheetName);
  return {
    listExistingNames: function (folderId) {
      const folder = DriveApp.getFolderById(folderId);
      const files = folder.getFiles();
      const names = new Set();
      while (files.hasNext()) {
        names.add(files.next().getName());
      }
      return names;
    },
    saveFile: function (folderId, blob, name) {
      const folder = DriveApp.getFolderById(folderId);
      const file = folder.createFile(blob).setName(name);
      return { url: file.getUrl() };
    },
    appendRow: function (_sheetName, row) {
      sheet.appendRow(row);
    },
    now: function () {
      return new Date();
    },
  };
}

/**
 * Main entry point — attach this to a time-driven trigger (see installTrigger()).
 */
function saveNewGmailAttachmentsToDrive() {
  const label = getOrCreateLabel_(CONFIG.GMAIL_LABEL_NAME);
  const ops = buildRealOps_(CONFIG.LOG_SPREADSHEET_ID, CONFIG.SHEET_NAME);
  const query = buildSearchQuery(CONFIG);
  const threads = GmailApp.search(query, 0, CONFIG.MAX_THREADS_PER_RUN);

  let totalSaved = 0;
  let totalSkipped = 0;

  for (let i = 0; i < threads.length; i++) {
    const thread = threads[i];
    try {
      const results = processThreadAttachments(thread, CONFIG, ops);
      totalSaved += results.saved.length;
      totalSkipped += results.skipped.length;
    } finally {
      // Label even if this thread had 0 matching attachments, so it's not
      // rescanned forever. Only skip labeling if processThreadAttachments itself threw.
      thread.addLabel(label);
    }
  }

  Logger.log('Threads scanned: %s | Files saved: %s | Skipped: %s', threads.length, totalSaved, totalSkipped);
}

/**
 * Run this once from the Apps Script editor to create the time-based trigger.
 * Safe to re-run — it won't create duplicate triggers for the same function.
 */
function installTrigger() {
  const already = ScriptApp.getProjectTriggers().some(
    (t) => t.getHandlerFunction() === 'saveNewGmailAttachmentsToDrive'
  );
  if (already) {
    Logger.log('Trigger already exists — skipping.');
    return;
  }
  ScriptApp.newTrigger('saveNewGmailAttachmentsToDrive')
    .timeBased()
    .everyHours(1)
    .create();
  Logger.log('Trigger installed: runs every hour.');
}

Setup: 8 steps

  1. Create (or pick) a Google Sheet that will hold the log, and copy its ID from the URL: docs.google.com/spreadsheets/d/THIS_PART/edit.
  2. Create (or pick) a Drive folder for saved attachments, open it, and copy its ID from the URL the same way.
  3. In the Sheet, go to Extensions > Apps Script. Delete the placeholder code and paste the script above.
  4. At the top of the script, replace DRIVE_FOLDER_ID and LOG_SPREADSHEET_ID with the two IDs from steps 1–2. Adjust GMAIL_SEARCH_QUERY if you want to scope it to one sender or label — see the configuration section below.
  5. In the function dropdown at the top of the editor, select installTrigger and click Run. Approve the OAuth consent screen when prompted (this script needs Gmail, Drive, and Sheets access).
  6. Confirm it worked: open Triggers (clock icon, left sidebar) and check that saveNewGmailAttachmentsToDrive is listed with an hourly schedule.
  7. Optional: select saveNewGmailAttachmentsToDrive in the function dropdown and click Run once manually to process existing mail immediately instead of waiting for the first trigger.
  8. Check Executions (left sidebar) after the first run to confirm it succeeded, then check the Drive folder and Sheet for results.

Two mistakes that break almost every version of this script

Answer capsule: nearly every Gmail-to-Drive tutorial online misses two specific things — both confirmed against Google’s own reference docs, not assumed. Getting these wrong doesn’t throw an error; it silently saves junk files or silently fails.

1. getAttachments() with no arguments also returns inline images. Gmail messages with an HTML signature (a logo, a headshot) technically carry that image as an inline attachment. Call message.getAttachments() with no options and Google’s own GmailMessage reference confirms it includes those inline images alongside real attachments — so a naive script saves a 4×4 pixel tracking image or a 20×20 logo to Drive next to your actual PDF. This script calls getAttachments({includeAttachments: true, includeInlineImages: false}) explicitly, and adds a minimum-size filter (MIN_ATTACHMENT_SIZE_BYTES) as a second line of defense for signature images small enough to slip through.

2. SpreadsheetApp.getActiveSpreadsheet() returns null on a trigger. This is the single most common way this exact kind of script silently fails. getActiveSpreadsheet() only works when a human is actively looking at a specific Sheet in a browser tab. A time-driven trigger runs with no browser open and no “active” spreadsheet, so getActiveSpreadsheet() returns null, and the next line (.getSheetByName(...)) throws TypeError: Cannot read properties of null. This script uses SpreadsheetApp.openById(LOG_SPREADSHEET_ID) instead, which works identically whether a human is watching or not.

Configuring what gets saved

Everything below is a field in the CONFIG object at the top of the script — no code changes needed to adjust behavior.

Field What it controls Example
GMAIL_SEARCH_QUERY Any Gmail search operator — scope this to a sender, label, or subject instead of the whole inbox from:billing@vendor.com has:attachment
MIN_ATTACHMENT_SIZE_BYTES Skip anything smaller than this — catches stray signature images includeInlineImages:false misses 15 * 1024 (15 KB)
EXCLUDED_CONTENT_TYPES Skip specific MIME types outright ['text/calendar'] to ignore .ics meeting invites
ALLOWED_EXTENSIONS If set, only save files with these extensions; null saves everything ['pdf', 'xlsx']
MAX_THREADS_PER_RUN Caps how many threads one run processes, to stay inside the 6-minute script runtime limit 50

How often it actually runs

Answer capsule: Gmail has no installable trigger for “new email arrived” the way Sheets has one for edits — Apps Script can only poll on a schedule. installTrigger() sets that schedule to once an hour; the fastest a time-driven trigger can run is every minute, but Google only allows five interval choices: 1, 5, 10, 15, or 30 minutes, or hourly and longer.

For most attachment-collecting use cases (invoices, submitted forms, contracts) hourly is fine — the file was going to sit in Drive either way. If you genuinely need near-real-time processing, change .everyHours(1) to .everyMinutes(5) in installTrigger(); true instant push notifications require the Gmail API’s Pub/Sub watch mechanism, which needs a separate Google Cloud project and is out of scope for a copy-paste Sheets script.

Errors you’ll hit

Error Cause Fix
TypeError: Cannot read properties of null (reading 'getSheetByName') LOG_SPREADSHEET_ID is still the placeholder, or points to a deleted file Paste the real Sheet ID from its URL
Exception: Invalid argument: id DRIVE_FOLDER_ID is wrong or the folder was deleted/moved to trash Re-copy the folder ID; restore from trash if needed
Service invoked too many times for one day: email. Gmail read/write quota exhausted for the day (20,000/day on a consumer account) Narrow GMAIL_SEARCH_QUERY or lower MAX_THREADS_PER_RUN
Exception: You do not have permission to call getAttachments OAuth consent was not completed, or a scope was later revoked Re-run installTrigger manually from the editor and re-approve the consent screen
Script runs but the Sheet never gets new rows GMAIL_LABEL_NAME was already applied to every matching thread in a previous run (or manually) Remove the label from a test thread in Gmail, or search -label:Attachments-Saved to see what is left unprocessed

Quotas that apply here

Verified against Google’s official Apps Script quotas reference on August 20, 2026.

Limit Consumer account Google Workspace account
Email read/write (excluding send) — this covers GmailApp.search and getAttachments 20,000 / day 50,000 / day
Time-driven triggers per script 20 20
Triggers total runtime 90 min / day 6 hr / day
Single script execution 6 min 6 min

Note this is a separate quota from the 100-recipient/day sending limit — reading mail and saving attachments does not touch your sending quota at all.

When this isn’t the right approach

If the trigger pattern you actually need is different, one of these existing guides is a better fit:

What I verified, and what I couldn’t

Verified today, in a Node.js sandbox: every pure decision function — shouldSaveAttachment (Google-type files, undersized images, excluded MIME types, extension allowlists), buildUniqueDriveFileName (collision handling across repeat filenames), buildSearchQuery, and the full processThreadAttachments orchestration against mocked Gmail/Drive/Sheet objects — 28 assertions total, covering duplicate filenames across messages in the same thread, Google Workspace file attachments that must never reach copyBlob(), and threads with zero qualifying attachments. The exact file that ships in this article was re-checked with node --check for syntax and re-run against the same 28 assertions after edits, not just the draft version.

Could not verify: the real Gmail/Drive/Sheets API calls themselves — GmailApp.search(), DriveApp.createFile(), the OAuth consent screen, and the actual trigger execution. Those require a live Google account and cannot run in a sandbox. The quota numbers and the getAttachments/getActiveSpreadsheet behavior above are cited from Google’s own reference docs (fetched today), not from running the script live.

FAQ

Will this grab attachments from emails I received before I set it up?
Yes — the first run scans everything matching GMAIL_SEARCH_QUERY (by default, the last 7 days), regardless of when you installed the trigger. Narrow the query (e.g., add after:2026/08/20) if you only want new mail going forward.

Can I limit it to one sender or one label instead of my whole inbox?
Yes. GMAIL_SEARCH_QUERY accepts the same operators as the Gmail search bar — combine from:, label:, or subject: with has:attachment, e.g. from:billing@vendor.com has:attachment.

What happens if two attachments end up with the same file name?
buildUniqueDriveFileName checks the destination folder’s existing names and appends (2), (3), and so on before the extension, so nothing gets silently overwritten — verified in the test suite above with two identically named invoice.pdf files arriving in the same thread.

Leave a Comment