appscript.dev
Automation Intermediate Gmail Drive

Auto-file quotes and proposals you receive

Detect inbound proposal PDFs and store them in Drive by client.

Published Jun 13, 2026

When a vendor sends Northwind a quote, the PDF should land in Drive › quotes/{vendor}/ automatically. In practice it sits in the inbox, gets half-noticed, and is impossible to find three weeks later when someone asks “what did the printers actually quote us?”.

This script watches recent mail for quotes and proposals. When a thread looks like a quote and carries a PDF, it saves the PDF into a per-vendor folder under a single quotes/ root, named from the sender’s domain. No dragging attachments around — every quote files itself the moment it arrives.

What you’ll need

  • A quotes/ root folder in Drive for the per-vendor sub-folders. You need its folder ID.
  • Mail that actually arrives in Gmail — the script searches recent threads with attachments, so a forwarding rule that strips attachments will defeat it.
  • Nothing else — the script creates the quotes/filed label and each vendor sub-folder itself.

The script

// Root Drive folder; per-vendor sub-folders are created inside it.
const QUOTES_ROOT = '1abcQuotesRootId';

// Words in a subject line that mark a thread as a quote or proposal.
const QUOTE_HINTS = /\b(quote|quotation|proposal|estimate|sow)\b/i;

// Label applied once a thread's PDFs are filed, so it is not filed twice.
const FILED_LABEL = 'quotes/filed';

/**
 * Scans recent mail for quote-like threads, saves any PDF attachments
 * into a per-vendor folder, and labels the thread as filed.
 */
function fileQuotes() {
  // 1. Recent threads with attachments that have not been filed yet.
  const threads = GmailApp.search('has:attachment newer_than:1d -label:quotes/filed');

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

  // Find or create the marker label once, outside the loop.
  const filed = GmailApp.getUserLabelByName(FILED_LABEL)
    || GmailApp.createLabel(FILED_LABEL);

  for (const t of threads) {
    // 2. Skip threads whose subject does not look like a quote.
    if (!QUOTE_HINTS.test(t.getFirstMessageSubject())) continue;

    // 3. Take PDF attachments from the most recent message.
    const msg = t.getMessages().slice(-1)[0];
    const pdfs = msg.getAttachments()
      .filter((a) => a.getContentType() === 'application/pdf');
    if (pdfs.length === 0) continue;

    // 4. Work out the vendor folder from the sender's address.
    const vendor = vendorFromEmail(msg.getFrom());
    const folder = getOrCreate(DriveApp.getFolderById(QUOTES_ROOT), vendor);

    // 5. Save each PDF into the vendor folder and label the thread.
    pdfs.forEach((a) => folder.createFile(a));
    t.addLabel(filed);
  }
}

/**
 * Derives a short vendor name from a "From" header — the first part of
 * the sender's domain, e.g. "[email protected]" -> "acme-print".
 */
function vendorFromEmail(from) {
  return ((from.match(/@([\w.-]+)/) || [, 'misc'])[1].split('.')[0]) || 'misc';
}

/**
 * Returns the named sub-folder inside parent, creating it if missing.
 */
function getOrCreate(parent, name) {
  const it = parent.getFoldersByName(name);
  return it.hasNext() ? it.next() : parent.createFolder(name);
}

How it works

  1. fileQuotes searches Gmail for threads from the last day that have an attachment and are not already labelled quotes/filed. If none match, it logs a message and stops.
  2. It resolves the quotes/filed label once, creating it on first run.
  3. For each thread it tests the subject of the first message against QUOTE_HINTS — a regex matching words like “quote”, “proposal” and “sow”. Threads that do not look like quotes are skipped.
  4. It takes the most recent message in the thread (slice(-1)[0]) and keeps only attachments whose content type is application/pdf. A thread with no PDF is skipped.
  5. vendorFromEmail derives a short vendor name from the sender’s address by pulling the domain and taking its first label — acme-print.com becomes acme-print, with misc as the fallback if the address cannot be parsed.
  6. getOrCreate finds the vendor’s sub-folder under quotes/, creating it the first time that vendor sends anything.
  7. Each PDF is saved into that folder with createFile, and the thread is labelled quotes/filed so the next run leaves it alone.

Example run

A vendor emails Northwind:

From: [email protected] Subject: Quotation for Q3 print run — attached Attachment: acme-q3-quote.pdf

After a run, Drive looks like this:

quotes/
  acme-print/
    acme-q3-quote.pdf

The thread is now labelled quotes/filed. A second quote from [email protected] lands in the same acme-print/ folder, because the vendor name comes from the domain, not the mailbox. A newsletter with a PDF attachment but a subject like “May updates” is ignored — it fails the QUOTE_HINTS test.

Trigger it

Run this on a frequent time-based trigger so quotes file themselves through the day:

  1. In the Apps Script editor open Triggers (the clock icon).
  2. Add a trigger for fileQuotes, Time-driven, Minutes timer, every 30 minutes.

Thirty minutes pairs well with the newer_than:1d search — even if a run is skipped, the next pass still catches anything from the last 24 hours.

Watch out for

  • The subject filter is a blunt instrument. A genuine quote with a vague subject is missed, and an unrelated mail that happens to say “estimate” is filed. Tune QUOTE_HINTS to the language your vendors actually use.
  • Only PDFs are saved. Vendors who send quotes as .docx or .xlsx slip through — widen the content-type filter, or pair this with Auto-convert uploaded Office files to Google formats.
  • Filenames are not de-duplicated. Two quotes named quote.pdf from the same vendor produce two files of the same name in one folder. Prefix the name with a date in createFile if that matters.
  • The vendor name is derived from the domain, so a vendor using a generic address like @gmail.com will be filed under gmail along with everyone else on that domain.
  • newer_than:1d plus the quotes/filed label means a quote only has a 24-hour window to be caught. If the script is paused for a day, older threads will not be filed retroactively — run it manually or widen the search to recover them.

Related