appscript.dev
Automation Intermediate Gmail Drive

Forward attachments to Drive and reply with the link

Strip large attachments out of incoming threads, save them to Drive, and reply with the link.

Published Jun 9, 2026

Large attachments do not belong in a mailbox. A 20MB mockup PDF sits in the thread forever, counts against the account’s storage, and is impossible to find again three weeks later when someone asks “where’s that file?”. The file should live in Drive, organised by client, and the email should just point at it.

When a client sends a heavy attachment to Northwind, this script saves it to Drive › clients/{name}, replies to the thread with a link, and labels the thread so it is never processed twice. It runs on a short timer, so files are offloaded within minutes of arriving — the sender gets a tidy link reply and the mailbox stays light.

What you’ll need

  • A clients/ Drive folder to act as the root for per-client subfolders. The script creates the subfolders itself, named after each sender’s domain.
  • Nothing else — the attachments/offloaded Gmail label is created on first run if it doesn’t already exist.

The script

// The Drive folder that holds one subfolder per client.
const CLIENTS_ROOT = '1abcClientsRootId';

// Only attachments at or above this size are offloaded. Smaller files
// stay in the thread where they're harmless.
const MIN_SIZE = 5 * 1024 * 1024; // 5MB

// The label marking a thread as already processed.
const DONE_LABEL = 'attachments/offloaded';

/**
 * Finds recent threads with large attachments, saves those attachments
 * to a per-client Drive folder, and replies with the links.
 */
function offloadAttachments() {
  // 1. Search for recent threads with attachments not yet offloaded.
  const threads = GmailApp.search(
    `has:attachment newer_than:1d -label:${DONE_LABEL}`);

  if (!threads.length) {
    Logger.log('No new threads with attachments — nothing to do.');
    return;
  }

  // Find or create the label that marks a thread as processed.
  const done = GmailApp.getUserLabelByName(DONE_LABEL)
    || GmailApp.createLabel(DONE_LABEL);

  let offloaded = 0;
  for (const t of threads) {
    // 2. Look at the most recent message in the thread.
    const msg = t.getMessages().slice(-1)[0];

    // 3. Keep only attachments at or above the size threshold.
    const big = msg.getAttachments()
      .filter((a) => a.getBytes().length >= MIN_SIZE);
    if (big.length === 0) continue;

    // 4. Resolve (or create) the Drive folder for this sender.
    const folder = clientFolder(msg.getFrom());

    // 5. Save each big attachment and collect a name-and-link line.
    const links = big.map((a) => {
      const file = folder.createFile(a);
      return `${a.getName()}: ${file.getUrl()}`;
    });

    // 6. Reply to the thread with the links, then label it done.
    t.reply(`Saved attachments to Drive:\n\n${links.join('\n')}\n\n— Northwind`);
    t.addLabel(done);
    offloaded += big.length;
  }
  Logger.log(`Offloaded ${offloaded} attachment(s).`);
}

/**
 * Returns the Drive folder for a sender, keyed by the first segment of
 * their email domain. Creates the folder on first sight of a client.
 */
function clientFolder(from) {
  // Pull the domain from the From header; "acme.com" -> "acme".
  const domain = (from.match(/@([\w.-]+)/) || [, 'misc'])[1].split('.')[0];
  const root = DriveApp.getFolderById(CLIENTS_ROOT);
  const it = root.getFoldersByName(domain);
  return it.hasNext() ? it.next() : root.createFolder(domain);
}

How it works

  1. offloadAttachments runs a Gmail search for threads from the last day that carry an attachment and are not yet labelled attachments/offloaded. If nothing matches, it stops.
  2. It finds or creates the attachments/offloaded label, then loops over each matching thread, looking only at the most recent message — the one that most likely carries the new attachment.
  3. It filters that message’s attachments down to ones at or above MIN_SIZE (5MB). Small files are left in the thread, where they cost nothing.
  4. For each thread with a big attachment, clientFolder resolves the right Drive subfolder from the sender’s email domain — for example, anything from @acme.com lands in a acme folder. If the folder doesn’t exist yet, it is created.
  5. Each large attachment is saved with folder.createFile, and a line pairing the file name with its Drive URL is collected.
  6. The script replies to the thread with all the links and adds the done label, so the same thread is never processed again on a later run.

Example run

A client at [email protected] emails Northwind with a 20MB file homepage-v3.pdf attached and a 40KB notes.txt.

On the next run the script picks up the thread, keeps only homepage-v3.pdf (the .txt is under 5MB), and saves it to clients/acme/. It then posts a reply on the thread:

Saved attachments to Drive:

homepage-v3.pdf: https://drive.google.com/file/d/.../view

— Northwind

The thread gets the attachments/offloaded label, and the log records Offloaded 1 attachment(s). A later run skips this thread entirely.

Trigger it

  1. In the Apps Script editor, open Triggers and click Add trigger.
  2. Function: offloadAttachments. Event source: time-based. Type: minutes timer, every 15 minutes.
  3. Save. Attachments are now offloaded within a quarter of an hour of arriving.

Watch out for

  • The script only inspects the last message in each thread. If a heavy attachment arrives on an earlier message in a long thread, it is missed — acceptable for fresh client threads, but worth knowing.
  • clientFolder keys folders off the domain’s first segment. Two unrelated clients on the same provider domain (for example, two @gmail.com senders) would share one folder. For a B2B inbox where everyone uses their own domain this is fine; for mixed senders, key off the full address instead.
  • a.getBytes().length loads the entire attachment into memory to measure it. For very large files this is slow and can press against script memory limits. If you process big files often, check a.getSize() where available rather than reading the bytes.
  • The newer_than:1d filter means a run that fails for more than a day can let threads age out of the search window unprocessed. The label still prevents duplicates, but widen the window if the trigger has been unreliable.
  • The script replies to the thread, which notifies the sender. If you would rather offload silently, drop the t.reply call and keep only the label.

Related