Build a shared team-inbox triage system
Round-robin assign incoming support@ mail to teammates by labelling each thread with an owner.
Published Dec 16, 2025
Northwind’s three-person support team works out of one shared support@
inbox, and the trouble with a shared inbox is ownership. Without a clear owner,
a thread either gets answered twice or not at all — everyone assumes someone
else has it. The team’s fix is to take turns, but doing the turn-taking by hand
is itself a chore.
This script automates the round-robin. It searches the shared inbox for new,
unassigned threads and labels each one with the next teammate in rotation, plus
a shared support/assigned label so the same thread is never handed out twice.
A cursor stored in Script Properties remembers whose turn is next, so the
rotation survives across runs.
What you’ll need
- A
Teamsheet with a header row and three columns:name,email, andgmailLabel. Each member gets a personal label such asowner/awadesh. - All three teammates must be able to see the shared inbox — either a delegated mailbox or a Workspace shared group that lands in each member’s Gmail.
- A
supportlabel (or filter) already applied to incoming mail, so the search has something to find.
The script
// The spreadsheet holding the support team roster.
const TEAM_SHEET_ID = '1abcTeamSheetId';
// The Gmail search for new threads that still need an owner.
const TRIAGE_QUERY = 'label:support is:unread -label:support/assigned';
// The shared label that marks a thread as already triaged.
const ASSIGNED_LABEL = 'support/assigned';
// The Script Property key that remembers whose turn is next.
const CURSOR_KEY = 'TRIAGE_CURSOR';
/**
* Finds new, unassigned support threads and labels each one with the
* next teammate in a sticky round-robin rotation.
*/
function triageSupportInbox() {
// 1. Load the team roster. With no team there is nobody to assign to.
const team = readSheet(TEAM_SHEET_ID);
if (!team.length) {
Logger.log('Team sheet is empty — nothing to triage.');
return;
}
// 2. Read the rotation cursor from where the last run left it.
const props = PropertiesService.getScriptProperties();
let cursor = parseInt(props.getProperty(CURSOR_KEY) || '0', 10);
// 3. Find new threads, and resolve the shared "assigned" label.
const incoming = GmailApp.search(TRIAGE_QUERY);
if (!incoming.length) {
Logger.log('No new threads to triage.');
return;
}
const assigned = GmailApp.getUserLabelByName(ASSIGNED_LABEL)
|| GmailApp.createLabel(ASSIGNED_LABEL);
// 4. Hand each thread to the next teammate in turn.
for (const thread of incoming) {
const member = team[cursor % team.length];
const label = GmailApp.getUserLabelByName(member.gmailLabel)
|| GmailApp.createLabel(member.gmailLabel);
thread.addLabel(label); // Tag the owner.
thread.addLabel(assigned); // Mark it as triaged so it is not reassigned.
cursor++;
}
// 5. Save the cursor so the next run picks up where this one stopped.
props.setProperty(CURSOR_KEY, String(cursor));
Logger.log('Triaged ' + incoming.length + ' thread(s).');
}
/**
* Reads a sheet's first tab and returns each row as an object keyed
* by the header row.
*/
function readSheet(id) {
const [h, ...rows] = SpreadsheetApp.openById(id).getSheets()[0]
.getDataRange().getValues();
return rows.map((r) => Object.fromEntries(h.map((k, i) => [k, r[i]])));
}
How it works
triageSupportInboxcallsreadSheetto load theTeamroster. If the sheet is empty there is nobody to assign work to, so it stops.- It reads the rotation cursor from Script Properties — a single number that
persists between runs and points at whose turn is next. A first run defaults
it to
0. - It searches Gmail with
TRIAGE_QUERYfor threads that are labelledsupport, still unread, and not yet carryingsupport/assigned. If there are none, it stops. It then resolves (or creates) the sharedassignedlabel. - For each new thread it picks
team[cursor % team.length]— the modulo wraps the cursor back to the start of the roster — adds that member’s personal label and the sharedassignedlabel, and advances the cursor by one. - After all threads are handled it writes the new cursor value back, so the next run continues the rotation rather than restarting it.
Example run
The Team sheet holds the roster:
| name | gmailLabel | |
|---|---|---|
| Awadesh | [email protected] | owner/awadesh |
| Bea | [email protected] | owner/bea |
| Carl | [email protected] | owner/carl |
Four new threads arrive with the cursor sitting at 2. The script hands them
out in turn and leaves the cursor at 6:
| Thread | Assigned to | Labels added |
|---|---|---|
| ”Login not working” | Carl | owner/carl, support/assigned |
| ”Refund request” | Awadesh | owner/awadesh, support/assigned |
| ”Feature question” | Bea | owner/bea, support/assigned |
| ”Cannot upload file” | Carl | owner/carl, support/assigned |
Each owner can now filter Gmail by their owner/... label to see only their
threads.
Trigger it
Run the triage often so new mail gets an owner quickly:
- In the Apps Script editor, open Triggers (the clock icon).
- Add a trigger for
triageSupportInbox, time-driven, on a minutes timer set to every 2 minutes — the shortest practical interval. - Save. New
supportthreads are assigned within a couple of minutes.
Watch out for
- The cursor is global and blind. It does not know who is busy or away — it just advances. If a teammate is on PTO, see Build a vacation-coverage auto-router.
- The search relies on the
supportlabel already being on incoming mail. Set up a Gmail filter to apply it, or the triage finds nothing. - A thread is only triaged once, because
support/assignedremoves it from the search. If you need to reassign, remove that label by hand to put it back in the pool. - Replies on an already-assigned thread will not be re-triaged — the owner label stays put, which is usually what you want.
GmailApp.searchreturns the most recent matches and is subject to Gmail’s result limits. On a very busy inbox, run the trigger more often rather than letting a backlog build.- Personal labels must match the
gmailLabelcolumn exactly. A mismatch silently creates a new, unintended label.
Related
Convert long email threads into a summary note
Collapse a thread's history into a Doc for handover — perfect for client transitions or vacation cover.
Updated Jun 6, 2026
Pull event RSVPs from emails into a Sheet
Parse yes/no replies to event invites and tally attendance automatically.
Updated Jun 2, 2026
Turn forwarded emails into project tasks
Forward to [email protected] and a row lands in the Projects sheet under the right client.
Updated May 30, 2026
Turn starred emails into a task list
Sync every starred thread into the Northwind Tasks sheet automatically.
Updated May 26, 2026
Alert when a label hits a backlog threshold
Warn the Northwind team in Slack when a Gmail label has more than N unread threads.
Updated Mar 31, 2026