appscript.dev
Automation Advanced Gmail Sheets

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 Team sheet with a header row and three columns: name, email, and gmailLabel. Each member gets a personal label such as owner/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 support label (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

  1. triageSupportInbox calls readSheet to load the Team roster. If the sheet is empty there is nobody to assign work to, so it stops.
  2. 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.
  3. It searches Gmail with TRIAGE_QUERY for threads that are labelled support, still unread, and not yet carrying support/assigned. If there are none, it stops. It then resolves (or creates) the shared assigned label.
  4. 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 shared assigned label, and advances the cursor by one.
  5. 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:

nameemailgmailLabel
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:

ThreadAssigned toLabels added
”Login not working”Carlowner/carl, support/assigned
”Refund request”Awadeshowner/awadesh, support/assigned
”Feature question”Beaowner/bea, support/assigned
”Cannot upload file”Carlowner/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:

  1. In the Apps Script editor, open Triggers (the clock icon).
  2. Add a trigger for triageSupportInbox, time-driven, on a minutes timer set to every 2 minutes — the shortest practical interval.
  3. Save. New support threads 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 support label 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/assigned removes 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.search returns 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 gmailLabel column exactly. A mismatch silently creates a new, unintended label.

Related