Picture a shared mailbox where every incoming document has to be read, filed to Drive, and handed to the right person — automatically. Now picture two scheduled runs waking at the same second, an upload that succeeds while its status write fails, and one odd email slipping through untouched.
In this tutorial, you’ll build a Google Workspace inbox agent that expects those failures. It polls Gmail, classifies messages, archives attachments in Drive, records durable status in Sheets, and sends exceptions to Google Chat. More importantly, it can safely see the same message twice without performing the same business action twice.
You’ll use Google Apps Script so the workflow stays close to the Google Workspace tools your team already uses. The finished design is suitable for a modest operations, accounts-payable, or document-intake mailbox and gives you a solid pattern to extend with an approved AI classifier later.
Prerequisites
This tutorial includes a hands-on build. To follow along, ensure you have:
-
A dedicated Google Workspace Gmail account, such as
[email protected]. Do not attach a production trigger to an employee’s personal account. -
Permission to create a Google Sheet, a Drive folder, an Apps Script project, and a Google Chat space webhook.
-
A Gmail filter that applies the
agent/pendinglabel to messages the agent should process. -
The Apps Script Advanced Gmail service enabled. In the script editor, click Services → Add a service → Gmail API → Add.
-
Sample emails containing harmless PDF, CSV, or image attachments.
This design targets a Gmail mailbox accessed by the automation owner or through delegation. A Google Groups Collaborative Inbox is a different product; do not assume a Group address can be passed to Gmail API methods as though it were a user mailbox.
Google notes that installable triggers run as the account that created them. Choose an automation owner whose access and employment lifecycle are intentionally managed.
Understanding the Reliable Inbox Pattern
You might be tempted to write one linear script: search Gmail, save files, and apply a “done” label. That works until Apps Script stops between the file upload and the label update. The next run sees the same email and saves another copy.
Instead, treat the workflow as a small state machine:
“`plain text
DISCOVERED → PROCESSING → CLASSIFIED → FILES_SAVED → COMPLETED
└──────────────────────────────→ EXCEPTION
A Google Sheet acts as the durable ledger. Each Gmail message ID has one logical row, including its status, attempt count, saved Drive IDs, and latest error. Gmail labels make the state visible to people, but the ledger is the source of truth. Why use the message ID? Subjects, filenames, senders, and timestamps can repeat. A thread ID is also insufficient because one thread can contain several messages and several sets of attachments. The Gmail API returns stable message and thread IDs; its message-list operation returns only those basic identifiers, so the agent retrieves each full message separately ([Gmail message listing documentation](https://developers.google.com/workspace/gmail/api/guides/list-messages)). The agent follows this order for each candidate: 1. Claim the message under an Apps Script lock. 2. Retrieve and classify the full message. 3. Save each attachment using a repeatable attachment key. 4. Record all resulting Drive IDs in the ledger. 5. Mark the ledger row `COMPLETED`. 6. Repair Gmail labels to reflect completion. If the final label update fails, a later run reads `COMPLETED`, repairs the labels, and skips every earlier business action.  ## Creating the Workspace Resources Start by creating the resources the script will use. 1. In Gmail, create these four labels: - `agent/pending` - `agent/processing` - `agent/completed` - `agent/exception` 2. Create a Google Drive folder named **Shared Inbox Archive** and copy its ID from the URL. In a URL such as `https://drive.google.com/drive/folders/ABC123`, the ID is `ABC123`. 3. Create a Google Sheet named **Shared Inbox Ledger**. Rename the first worksheet **Ledger**, and add this header row: ```plain text message_id | thread_id | received_at | sender | subject | category | confidence | status | attempt_count | attachment_count | saved_files_json | drive_folder_id | last_step | last_error_code | last_error_detail | first_seen_at | updated_at | completed_at | lease_until | chat_alert_key
-
In Google Chat, open the space that should receive operational exceptions. Choose Apps & integrations → Webhooks, create a webhook named Inbox Agent, and copy its URL. A Chat incoming webhook sends one-way notifications; it cannot support an interactive approve/reject workflow (Google Chat webhook documentation).
-
Create a standalone Apps Script project named Resilient Inbox Agent.

Storing Configuration Outside the Code
In Apps Script, open Project Settings → Script properties, and add these properties:
[!WARNING]Never paste a Chat webhook URL into source code or a Sheet cell. Anyone with read access to the code or spreadsheet could post to your Chat space. Script properties keep the secret scoped to the project.
| Property | Value |
|---|---|
SPREADSHEET_ID |
ID of the ledger Sheet |
ARCHIVE_FOLDER_ID |
ID of Shared Inbox Archive |
CHAT_WEBHOOK_URL |
Incoming webhook URL |
CONFIDENCE_THRESHOLD |
0.85 |
MAX_MESSAGES_PER_RUN |
20 |
Script properties provide script-scoped key/value storage (PropertiesService reference). Access to the Apps Script project must still be restricted because project editors can read these values.
Add the following foundation to Code.gs:
const HEADERS = [
'message_id', 'thread_id', 'received_at', 'sender', 'subject',
'category', 'confidence', 'status', 'attempt_count',
'attachment_count', 'saved_files_json', 'drive_folder_id',
'last_step', 'last_error_code', 'last_error_detail',
'first_seen_at', 'updated_at', 'completed_at', 'lease_until',
'chat_alert_key'
];
function config_() {
const p = PropertiesService.getScriptProperties().getProperties();
['SPREADSHEET_ID', 'ARCHIVE_FOLDER_ID', 'CHAT_WEBHOOK_URL'].forEach(k => {
if (!p[k]) throw new Error(`Missing script property: ${k}`);
});
return {
spreadsheetId: p.SPREADSHEET_ID,
archiveFolderId: p.ARCHIVE_FOLDER_ID,
chatWebhookUrl: p.CHAT_WEBHOOK_URL,
confidenceThreshold: Number(p.CONFIDENCE_THRESHOLD || 0.85),
maxMessages: Number(p.MAX_MESSAGES_PER_RUN || 20)
};
}
function sheet_() {
return SpreadsheetApp.openById(config_().spreadsheetId)
.getSheetByName('Ledger');
}
function rowObject_(values) {
return HEADERS.reduce((o, h, i) => {
o[h] = values[i];
return o;
}, {});
}
function findRow_(messageId) {
const sheet = sheet_();
const lastRow = sheet.getLastRow();
if (lastRow < 2) return null;
const values = sheet.getRange(2, 1, lastRow - 1, HEADERS.length).getValues();
const index = values.findIndex(r => String(r[0]) === String(messageId));
return index < 0 ? null : { row: index + 2, data: rowObject_(values[index]) };
}
function writeRow_(row, data) {
sheet_().getRange(row, 1, 1, HEADERS.length)
.setValues([HEADERS.map(h => data[h] ?? '')]);
SpreadsheetApp.flush();
}
This implementation scans the ledger to find an ID, which is acceptable for a modest inbox. At higher volume, replace Sheets with a datastore that supports unique keys and conditional writes.
Claiming Each Message Exactly Once
Two time-driven executions can overlap. Both could check the Sheet, see no row, and append one. Prevent that race by putting the check and claim in the same script lock. Apps Script’s LockService prevents concurrent execution of this short critical section.
Add the claim function:
function claimMessage_(message) {
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
const now = new Date();
const found = findRow_(message.id);
if (found && found.data.status === 'COMPLETED') {
return { action: 'REPAIR', row: found.row, data: found.data };
}
if (found && found.data.status === 'PROCESSING' &&
new Date(found.data.lease_until) > now) {
return { action: 'SKIP' };
}
const data = found ? found.data : Object.fromEntries(HEADERS.map(h => [h, '']));
data.message_id = message.id;
data.thread_id = message.threadId;
data.status = 'PROCESSING';
data.last_step = 'CLAIMED';
data.attempt_count = Number(data.attempt_count || 0) + 1;
data.first_seen_at = data.first_seen_at || now;
data.updated_at = now;
data.lease_until = new Date(now.getTime() + 10 * 60 * 1000);
const row = found ? found.row : sheet_().getLastRow() + 1;
writeRow_(row, data);
return { action: 'PROCESS', row, data };
} finally {
lock.releaseLock();
}
}
function transition_(row, changes) {
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
const current = rowObject_(
sheet_().getRange(row, 1, 1, HEADERS.length).getValues()[0]
);
Object.assign(current, changes, { updated_at: new Date() });
writeRow_(row, current);
return current;
} finally {
lock.releaseLock();
}
}
Notice that the lock is released before Gmail retrieval, classification, and uploads. Holding it during network operations would serialize the entire agent and increase timeout risk. The ten-minute lease lets a later run reclaim work if an execution terminates unexpectedly.
[!TIP]Keep the locked section as short as possible: read the ledger row, write the claim, release. Every network call you pull inside the lock is time another execution spends blocked, and Apps Script kills executions that run too long.
Retrieving and Classifying Messages
The Gmail payload is a MIME tree. Attachments can be nested several levels deep, and the bytes can be inline in body.data or available through an attachmentId. Gmail encodes this data with base64url (attachment retrieval reference).
First, add helpers for headers and deterministic classification:
function header_(message, name) {
const headers = message.payload.headers || [];
const found = headers.find(h => h.name.toLowerCase() === name.toLowerCase());
return found ? found.value : '';
}
function classify_(message) {
const sender = header_(message, 'From').toLowerCase();
const subject = header_(message, 'Subject').toLowerCase();
if (/@trustedvendor\.example\b/.test(sender) && /\binvoice\b/.test(subject)) {
return { category: 'invoice', confidence: 0.99,
reason_code: 'TRUSTED_VENDOR_INVOICE', needs_human: false };
}
if (/\bpurchase order\b|\bpo[- ]?\d+\b/.test(subject)) {
return { category: 'purchase_order', confidence: 0.95,
reason_code: 'PURCHASE_ORDER_SUBJECT', needs_human: false };
}
if (/\bsupport\b|\bhelp\b/.test(subject)) {
return { category: 'support', confidence: 0.90,
reason_code: 'SUPPORT_SUBJECT', needs_human: false };
}
return { category: 'unknown', confidence: 0.40,
reason_code: 'NO_APPROVED_RULE', needs_human: true };
}
Replace trustedvendor.example with a domain you control. Anchored rules and approved sender lists should handle obvious messages before any AI call.
If you later connect an AI classifier, require a strict object containing an allowed category, confidence from zero to one, a reason code, and needs_human. Email text is untrusted input. Never let message content or model output choose a Drive folder, webhook, recipient, label, or arbitrary action. Map validated categories to administrator-controlled policy.
This is also where outside expertise can help: if your workflow crosses accounting, document management, or approval systems, Adam can help automate the complete business process rather than treating inbox classification as an isolated script.
Saving Nested Attachments Without Duplicates
Use a compound attachment key made from the Gmail message ID and MIME part path. Filenames alone are unsafe because two attachments can share the same name.
Add these functions:
function sanitizeFilename_(name) {
return String(name || 'attachment')
.replace(/[\\/:*?"<>|\x00-\x1F]/g, '_')
.slice(0, 180);
}
function collectAttachments_(message, part, path, output) {
const currentPath = path || '0';
const body = part.body || {};
if (part.filename && (body.attachmentId || body.data)) {
let bytes;
if (body.attachmentId) {
const a = Gmail.Users.Messages.Attachments.get(
'me', message.id, body.attachmentId
);
bytes = Utilities.base64DecodeWebSafe(a.data);
} else {
bytes = Utilities.base64DecodeWebSafe(body.data);
}
output.push({
key: `${message.id}:${currentPath}:${body.attachmentId || 'inline'}`,
name: sanitizeFilename_(part.filename),
mimeType: part.mimeType || 'application/octet-stream',
bytes: bytes
});
}
(part.parts || []).forEach((child, i) =>
collectAttachments_(message, child, `${currentPath}.${i}`, output)
);
return output;
}
function getOrCreateMessageFolder_(data) {
if (data.drive_folder_id) {
try { return DriveApp.getFolderById(data.drive_folder_id); } catch (e) {}
}
const root = DriveApp.getFolderById(config_().archiveFolderId);
return root.createFolder(data.message_id);
}
function saveAttachments_(message, row, data) {
const files = JSON.parse(data.saved_files_json || '{}');
const folder = getOrCreateMessageFolder_(data);
transition_(row, { drive_folder_id: folder.getId() });
const attachments = collectAttachments_(message, message.payload, '0', []);
attachments.forEach(a => {
if (files[a.key]) {
try {
DriveApp.getFileById(files[a.key]);
return;
} catch (e) {
delete files[a.key];
}
}
const blob = Utilities.newBlob(a.bytes, a.mimeType, a.name);
const file = folder.createFile(blob);
if (file.getSize() !== a.bytes.length) {
file.setTrashed(true);
throw new Error(`SIZE_MISMATCH:${a.name}`);
}
files[a.key] = file.getId();
transition_(row, { saved_files_json: JSON.stringify(files) });
});
return { count: attachments.length, files, folderId: folder.getId() };
}
The script records every file ID immediately after upload. For stronger recovery from the narrow “upload succeeded, Sheet write failed” window, use the Advanced Drive service to attach the compound key as an appProperties value and query that property before creating a file. The Drive upload guide also recommends resumable uploads for large files or unreliable network conditions.
Before using this in production, add your organization’s file-size limit and MIME allowlist. Do not trust filename extensions. Route executables, encrypted archives, unexpected MIME types, and files requiring malware inspection to an exception path.
Routing Exceptions to Google Chat
An exception alert needs enough context to act, but it should not copy sensitive message bodies into Chat. Use an alert key so repeated runs do not create an alert storm.
function sendException_(row, data, code, detail) {
const alertKey = `${data.message_id}:${code}:v1`;
if (data.chat_alert_key === alertKey) return;
const safeDetail = String(detail).replace(/[\r\n]+/g, ' ').slice(0, 300);
const text = [
`Shared Inbox Exception: ${code}`,
`Message: ${data.message_id}`,
`From: ${data.sender || '(unknown)'}`,
`Subject: ${data.subject || '(none)'}`,
`Attempt: ${data.attempt_count}`,
`Detail: ${safeDetail}`,
'Action: Review the ledger and source message.'
].join('\n');
const response = UrlFetchApp.fetch(config_().chatWebhookUrl, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify({ text }),
muteHttpExceptions: true
});
if (response.getResponseCode() < 200 || response.getResponseCode() >= 300) {
throw new Error(`CHAT_HTTP_${response.getResponseCode()}`);
}
transition_(row, { chat_alert_key: alertKey });
}
A webhook is appropriate for notification, not approval. If operators need buttons that classify, approve, or retry a message directly in Chat, build an authenticated Google Chat app with interactive cards instead.
Orchestrating the Complete Run
Now connect the pieces. The Gmail query is intentionally bounded to pending messages from the last 14 days. Continue pagination in a larger implementation, but cap each execution so one busy mailbox cannot consume the entire Apps Script runtime.
function repairLabels_(messageId, exception) {
const addLabelIds = [labelId_(exception ? 'agent/exception' : 'agent/completed')];
const removeLabelIds = [
labelId_('agent/pending'), labelId_('agent/processing')
];
Gmail.Users.Messages.modify({ addLabelIds, removeLabelIds }, 'me', messageId);
}
function labelId_(name) {
const labels = Gmail.Users.Labels.list('me').labels || [];
const label = labels.find(l => l.name === name);
if (!label) throw new Error(`Missing Gmail label: ${name}`);
return label.id;
}
function processInbox() {
const cfg = config_();
const query = 'label:agent/pending -label:agent/completed ' +
'-label:agent/exception newer_than:14d';
const listed = Gmail.Users.Messages.list('me', {
q: query,
maxResults: cfg.maxMessages
});
(listed.messages || []).forEach(ref => {
let claim;
try {
claim = claimMessage_(ref);
if (claim.action === 'SKIP') return;
if (claim.action === 'REPAIR') {
repairLabels_(ref.id, false);
return;
}
const message = Gmail.Users.Messages.get('me', ref.id, { format: 'full' });
const sender = header_(message, 'From');
const subject = header_(message, 'Subject');
let data = transition_(claim.row, {
sender, subject,
received_at: new Date(Number(message.internalDate)),
last_step: 'RETRIEVED'
});
const result = classify_(message);
data = transition_(claim.row, {
category: result.category,
confidence: result.confidence,
status: 'CLASSIFIED',
last_step: 'CLASSIFIED'
});
if (result.needs_human || result.confidence < cfg.confidenceThreshold) {
data = transition_(claim.row, {
status: 'EXCEPTION',
last_error_code: 'LOW_CONFIDENCE',
last_error_detail: result.reason_code,
lease_until: ''
});
sendException_(claim.row, data, 'LOW_CONFIDENCE', result.reason_code);
repairLabels_(ref.id, true);
return;
}
const saved = saveAttachments_(message, claim.row, data);
data = transition_(claim.row, {
attachment_count: saved.count,
saved_files_json: JSON.stringify(saved.files),
drive_folder_id: saved.folderId,
status: 'FILES_SAVED',
last_step: 'FILES_SAVED'
});
transition_(claim.row, {
status: 'COMPLETED',
last_step: 'COMPLETED',
completed_at: new Date(),
lease_until: '',
last_error_code: '',
last_error_detail: ''
});
repairLabels_(ref.id, false);
} catch (error) {
if (!claim || !claim.row) throw error;
const data = transition_(claim.row, {
status: 'EXCEPTION',
last_error_code: 'PROCESSING_FAILED',
last_error_detail: String(error.message || error).slice(0, 500),
lease_until: ''
});
sendException_(claim.row, data, 'PROCESSING_FAILED', error.message || error);
repairLabels_(ref.id, true);
}
});
}
The Gmail API supports adding and removing labels with messages.modify (method reference). Labels remain a repairable projection: the script commits COMPLETED to the ledger before changing Gmail.
For transient HTTP 429 and 5xx errors, add exponential backoff with jitter rather than immediately converting the message to a permanent exception. Keep the run bounded and consult the live Apps Script quotas page, because limits can change.

Scheduling and Testing the Agent
Scheduling the Trigger
In Apps Script, click Triggers → Add Trigger. Choose processInbox, select Time-driven, and run it every five minutes. Google does not guarantee real-time execution, so the workflow must tolerate delays and overlap.
Testing the Failure Paths
Do not call the build finished after one successful email. Test the failures that reveal whether the design is truly recoverable:
| Test | Expected result |
|---|---|
Run processInbox twice for one message |
One ledger row and one copy of each Drive file |
| Start two runs close together | One run claims the message; the other skips it |
| Send two attachments with the same filename | Both files are retained under different attachment keys |
Set the confidence below 0.85 |
No files are processed automatically; one Chat alert appears |
| Remove Gmail label permission after completion | Ledger remains COMPLETED; a later run repairs the label |
Put an expired time in lease_until |
A later execution reclaims the interrupted message |
| Send a nested forwarded MIME message | Recursive traversal finds eligible attachments |
| Break the Chat webhook | The ledger preserves the exception and does not claim success |
Also add a daily reconciliation function for production use. It should inspect completed ledger rows, verify recorded Drive file IDs still exist, and restore missing Gmail completion labels. Reconciliation turns silent drift into repairable work.
For larger mailboxes or near-real-time intake, move from polling to Gmail push notifications through Cloud Pub/Sub. Gmail requires mailbox watches to be renewed at least every seven days and warns that notifications can be delayed or dropped, so keep a fallback synchronization path (Gmail push notification guide). If a stored history ID becomes invalid, Gmail’s synchronization guidance calls for a full synchronization (Gmail synchronization guide). Push changes latency, not the need for idempotency and reconciliation.
Securing the Production Workflow
Before processing real business documents, review these controls:
-
Grant the narrowest practical Gmail, Drive, and Sheets access. Google classifies several mailbox scopes as restricted; review the current Gmail scope requirements.
-
Restrict the archive folder, ledger, Apps Script project, and Chat space to the operations team.
-
Keep message bodies, attachment contents, tokens, and webhook URLs out of logs.
-
Treat subjects, bodies, filenames, and attachments as untrusted input.
-
Enforce MIME, size, sender, and category policies before any downstream action.
-
Record classifier and policy versions when adding AI classification.
-
Define retention rules for archived files and ledger rows.
-
Review
EXCEPTIONrecords as a dead-letter queue with a named owner and response target.
Exactly-once execution is not available across Gmail, Sheets, Drive, and Chat as one distributed transaction. What you can achieve is exactly-once business effects through stable IDs, locked claims, idempotent writes, durable state, and reconciliation.
From Email Sorter to Reliable Agent
You’ve built more than an email sorter. Your Google Workspace inbox agent now claims messages safely, classifies them through controlled rules, walks nested MIME structures, archives attachments, records every durable transition, and sends deduplicated exceptions to Google Chat.
The central reliability lesson is simple: expect every step to run again. Keep the Gmail message ID as the correlation key, the Sheet ledger as the source of truth, and Gmail labels as a view that can be repaired. With those safeguards in place, you can extend classification with a governed AI model or connect the workflow to accounting and service systems without turning a partial failure into duplicate business work.