Auto-file Gmail attachments to Drive by sender and type
Attachments pile up in a shared inbox and someone has to open each email, work out whose file it is, and save it to the right Drive folder by hand.
Attachments arriving in a shared Gmail inbox can be filed to the right Google Drive folder on arrival, using rules that match the sender and file type, so nobody opens each email to save its file by hand. Evenops builds this as Apps Script you own, and a new sender or file type is handled by adding a rule, not rewriting the script.
How does it run?
3 stepsStep 01Trigger
New email with an attachment arrives in Gmail
Step 02Match
Sender and file type matched to a folder rule
Step 03Save
File saved to the matching Drive folder
What do you get?
3 outcomes- ✓Filing happens on arrival, not in an end-of-day batch
- ✓Drive folder structure stays consistent without manual sorting
- ✓A new sender or file type is handled by adding a rule, not a rewrite
Which tools does it use?
Time saved
Replaces manual sorting of email attachments into Drive.What does the script do?
A time-driven Apps Script that looks at new Gmail threads with attachments every ten minutes, matches the sender and file type against a list of rules you write, and saves each attachment into the matching Drive folder. Anything from a sender that matches no rule goes to an Unsorted folder, so nothing is lost and nothing is guessed, and you can list senders such as noreply addresses to ignore completely. Each file is renamed with the date and part of the message ID, so two emails that both attach invoice.pdf never overwrite each other and a re-run does not create copies.
What do you need first?
- A Gmail or Google Workspace account that receives the attachments, and a Drive folder to file them into.
- The ID of that Drive folder (the long string at the end of its URL when you open it).
- A list of sender addresses and the folder name each should land in. A part of the address such as @supplier-b.example is enough.
- About 20 minutes. No paid add-on and no Google Cloud project are needed.
How do you set it up?
7 stepsCreate the script
Open script.google.com while signed in to the account that receives the email and choose New project. Rename it to something your team will recognise, such as Attachment filing.
Paste the code
Replace the contents of Code.gs with the script below.
Fill in CONFIG
Set ROOT_FOLDER_ID to your folder ID, ALERT_TO to the person who should hear about failures, and write one line per sender in RULES. Leave types out of a rule to accept any file type from that sender. Add any sender whose attachments you never want (newsletters, no-reply addresses) to EXCLUDE_SENDERS.
Run it once by hand
Pick fileAttachments in the function dropdown and press Run. Google asks you to authorise Gmail, Drive and email access. A warning that the app is not verified is normal for a script you wrote yourself in your own account.
Check the result
Open the Drive folder. You should see one sub-folder per rule with the files inside, and a new Filed-to-Drive label on the Gmail threads that were handled.
Install the trigger
Pick installTrigger and press Run once. It creates a trigger that runs fileAttachments every ten minutes and removes any earlier copy, so running it twice is safe. You can see it under Triggers in the left menu.
Turn on failure emails
In Triggers, open the trigger, choose Failure notification settings and set it to notify you immediately. This catches the case where the script itself stops running, which the script cannot email you about.
The script
// Files Gmail attachments into Drive folders by sender and file type.
// Paste into Code.gs in a new project at script.google.com, edit CONFIG, run installTrigger() once.
const CONFIG = {
ROOT_FOLDER_ID: 'PASTE_THE_DRIVE_FOLDER_ID_HERE', // the long ID at the end of the folder URL
DONE_LABEL: 'Filed-to-Drive', // created automatically
SEARCH_WINDOW: 'newer_than:7d',
MAX_THREADS: 25, // per run
// Stop well before the 6 minute limit. On a gmail.com account the whole day's
// trigger time is 90 minutes, so lower this (for example 60 * 1000) there.
TIME_BUDGET_MS: 4 * 60 * 1000,
FALLBACK_FOLDER: 'Unsorted', // senders that match no rule land here
EXCLUDE_SENDERS: ['noreply@', 'no-reply@'], // parts of From addresses to ignore completely
ALERT_TO: 'ops@example.com',
ALERT_EVERY_MS: 6 * 60 * 60 * 1000, // at most one alert email per 6 hours
RULES: [
// sender: part of the From address. types: allowed content types (omit for any).
{ sender: 'accounts@supplier-a.example', types: ['application/pdf'], folder: 'Supplier A' },
{ sender: '@supplier-b.example', folder: 'Supplier B' },
],
};
function fileAttachments() {
const lock = LockService.getScriptLock();
if (!lock.tryLock(30 * 1000)) return; // the previous run is still going
const started = Date.now();
const problems = [];
try {
const root = DriveApp.getFolderById(CONFIG.ROOT_FOLDER_ID);
const label = GmailApp.getUserLabelByName(CONFIG.DONE_LABEL) || GmailApp.createLabel(CONFIG.DONE_LABEL);
const query = 'has:attachment ' + CONFIG.SEARCH_WINDOW + ' -label:' + CONFIG.DONE_LABEL;
const folders = {}; // folder name -> Folder, looked up once per run
const threads = GmailApp.search(query, 0, CONFIG.MAX_THREADS);
for (const thread of threads) {
if (Date.now() - started > CONFIG.TIME_BUDGET_MS) break; // the next run picks up the rest
try {
for (const message of thread.getMessages()) {
fileMessage_(message, root, folders);
}
thread.addLabel(label); // only after every attachment in the thread is saved
} catch (err) {
problems.push(subjectOf_(thread) + ' -> ' + err.message);
}
}
} catch (err) {
problems.push('Run failed: ' + err.message);
} finally {
lock.releaseLock();
}
alertOnce_(problems);
}
function fileMessage_(message, root, folders) {
const from = message.getFrom().toLowerCase();
const excluded = CONFIG.EXCLUDE_SENDERS.some(function (s) { return from.indexOf(s.toLowerCase()) !== -1; });
if (excluded) return;
const day = Utilities.formatDate(message.getDate(), Session.getScriptTimeZone(), 'yyyy-MM-dd');
const shortId = message.getId().slice(-6);
const attachments = message.getAttachments({ includeInlineImages: false });
attachments.forEach(function (att, i) {
const rule = matchRule_(from, String(att.getContentType()));
const folder = folderFor_(root, folders, rule ? rule.folder : CONFIG.FALLBACK_FOLDER);
// Date + message ID + position in the name makes a re-run harmless, stops two
// emails with "invoice.pdf" overwriting each other, and keeps two same-name
// files in one email apart.
const name = day + '_' + shortId + '-' + (i + 1) + '_' + (att.getName() || 'attachment');
if (folder.getFilesByName(name).hasNext()) return;
folder.createFile(att.copyBlob().setName(name));
});
}
function matchRule_(from, contentType) {
const type = contentType.toLowerCase();
return CONFIG.RULES.find(function (r) {
return from.indexOf(r.sender.toLowerCase()) !== -1 &&
(!r.types || r.types.some(function (t) { return t.toLowerCase() === type; }));
});
}
// A broken thread must not stop the report on it, so never let this throw.
function subjectOf_(thread) {
try {
return thread.getFirstMessageSubject();
} catch (err) {
return '(unreadable thread)';
}
}
function folderFor_(root, cache, name) {
if (!cache[name]) {
const found = root.getFoldersByName(name);
cache[name] = found.hasNext() ? found.next() : root.createFolder(name);
}
return cache[name];
}
function alertOnce_(problems) {
if (!problems.length) return;
const props = PropertiesService.getScriptProperties();
const last = Number(props.getProperty('lastAlertAt') || 0);
if (Date.now() - last < CONFIG.ALERT_EVERY_MS) return;
MailApp.sendEmail(
CONFIG.ALERT_TO,
'Attachment filing needs attention (' + problems.length + ')',
problems.join('\n')
);
props.setProperty('lastAlertAt', String(Date.now()));
}
// Run once by hand. Safe to run again: it replaces the old trigger.
function installTrigger() {
ScriptApp.getProjectTriggers()
.filter(function (t) { return t.getHandlerFunction() === 'fileAttachments'; })
.forEach(function (t) { ScriptApp.deleteTrigger(t); });
ScriptApp.newTrigger('fileAttachments').timeBased().everyMinutes(10).create();
}
Every Apps Script method in this script was checked against Google's Apps Script reference (developers.google.com/apps-script/reference) on 11/10/2026. Limits come from Google's quota table, checked the same day.
What should you know before relying on it?
9 caveats- Quotas
- Google's quota table lists a 6 minute limit per execution, 20 triggers per user per script, and a total trigger runtime of 90 minutes a day on a gmail.com account or 6 hours a day on Google Workspace. Gmail read and write operations (excluding send) are capped at 20,000 a day on gmail.com and 50,000 a day on Workspace. The trigger-runtime budget is the one to watch. This trigger runs every 10 minutes, which is 144 runs a day. On a gmail.com account that budget works out to an average of about 37 seconds per run; on Workspace, about 150. A run that finds nothing new is short, but a backlog run that uses its full 4 minute budget costs 240 seconds, so about 22 of those use up a gmail.com day. Open Executions in the Apps Script editor to see how long your runs really take. To tune it: lengthen the interval by editing everyMinutes in the install function (Google allows 1, 5, 10, 15 or 30; for longer use everyHours), lower TIME_BUDGET_MS, or narrow the search so fewer threads are read. Every thread this script reads also counts towards the Gmail figure, which is why it reads at most 25 threads per run and skips threads already labelled.
- The six minute limit
- A single run is stopped at 6 minutes. The script stops itself after 4 minutes and the next run carries on, because finished threads are labelled and unfinished ones are not.
- Running twice at once
- The script takes a lock before it starts. If a run is still going when the next trigger fires, the new run exits quietly instead of filing the same attachment twice.
- Idempotency
- The thread label is added only after every attachment in that thread is saved, and each file name includes the date, part of the message ID and the attachment's position, so two attachments with the same name in one email are both kept. If a run dies halfway, the next run saves what is missing and skips what exists.
- Unmatched senders
- A sender with no rule is not ignored: its attachments are saved to the Unsorted folder, including logos and files you do not want. Check Unsorted weekly and either add a rule or add the sender to EXCLUDE_SENDERS.
- Replies on a labelled thread
- Once a thread is labelled it is not looked at again, so an attachment that arrives later in the same conversation is missed. If that happens in your inbox, remove the label from the thread or ask us to switch to per-message tracking.
- Who gets alerted
- Failures on a thread are collected and emailed to ALERT_TO, at most once every 6 hours so a persistent fault does not fill the inbox. Make ALERT_TO a shared mailbox rather than one person, so the alert survives holidays and departures.
- Ownership
- The script runs as the account that installed it and files into that account's Drive. If that person leaves and their account is suspended, the script stops. Install it from a dedicated shared Workspace user (for example automations@yourcompany.example) that the business owns and keeps active.
- Window
- The search looks back 7 days (SEARCH_WINDOW). If the script is paused for longer than that, older attachments are not picked up. Widen the window before restarting after a long gap.
When should you call us?
A script like this is the right size when one inbox feeds a handful of folders. Call us when the rules grow past what a list can hold, when files need to be renamed from what is inside them, when several mailboxes need the same logic, or when this has to be monitored and kept working without anyone on your team owning it. We build the same thing as code you own, with logging, alerting and a runbook.
Tell us what you needContinues · Want this wired into your stack?
Want this wired into your stack?
Tell us your tools and the task. We'll scope it and build it as production code you own.
We typically respond within 24 hours
