Skip to content
// Gmail · Sheets · Apps Script

Log incoming emails to a sheet in Gmail.

A step-by-step guide to using Google Apps Script to log Gmail messages to a Google Sheet, using a Logged label to prevent duplicate rows on every timed trigger run.

I want to automatically capture incoming Gmail messages into a Google Sheet so I can track, filter, or report on them without manually copying anything.

The script

copy · paste · trigger
logEmails.gs
Apps Script
// Log unprocessed Gmail threads to a Sheet, then label them to avoid re-logging
function logEmailsToSheet() {
  var label = GmailApp.getUserLabelByName('Logged') ||
              GmailApp.createLabel('Logged');
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Emails');
  var threads = GmailApp.search('-label:Logged in:inbox');
  for (var i = 0; i < threads.length; i++) {
    var msg = threads[i].getMessages()[0];
    sheet.appendRow([
      msg.getDate(),
      msg.getFrom(),
      msg.getSubject(),
      msg.getPlainBody().substring(0, 500)
    ]);
    threads[i].addLabel(label);
  }
}

Need a variant? Gnaw writes a custom version from one sentence — fields, triggers, edge cases handled.

Walkthrough

Why the search query is the whole trick

Gmail's search API is stateless. Every time your script calls GmailApp.search(), it re-scans your inbox from scratch against whatever query string you pass in. There is no cursor, no offset, no marker that says "start where I left off." If you search for 'in:inbox' on a 15-minute trigger, you will log every thread in your inbox on every run — duplicating rows until the sheet is worthless.

The fix is a label. Gmail labels are persistent metadata on threads; they survive across sessions, API calls, and time. The search query '-label:Logged in:inbox' tells Gmail to return only threads that do not have the Logged label. After the script appends a row for a thread, it immediately calls addLabel() on that thread, so the next trigger cycle skips it. The label acts as a processed-set, and the query exclusion makes the whole operation idempotent — safe to run as often as you want.

One edge case worth knowing: GmailApp.search() caps at 500 results per call. If a label-blast or newsletter flood drops more than 500 unlabeled threads into your inbox between runs, you will fall behind. For high-volume inboxes, drop the trigger interval or add a batch loop that calls search() in pages using the start parameter.

Setting up the sheet and trigger

Create a new Google Sheet, rename the first tab to 'Emails', and add headers in row 1: Date, From, Subject, Body. The script uses getSheetByName('Emails') directly, so the tab name must match exactly — Apps Script does not auto-create the tab.

Open Extensions > Apps Script, paste the function, and save. To run it manually first: click Run, accept the OAuth consent screen (it will ask for Gmail read/write and Sheets access), and check the sheet for rows. If the Logged label does not exist yet in Gmail, getUserLabelByName() returns null and the script creates it via createLabel() — you will see it appear in your Gmail sidebar after the first run.

To automate it, go to Triggers (the clock icon in the sidebar), add a new trigger for logEmailsToSheet, choose Time-driven, and pick your interval. Every 15 minutes is a reasonable starting point. The first time I set this up I used an hourly trigger and missed a time-sensitive order confirmation — 15 minutes is the sweet spot for anything operational.

Narrowing the search to a specific sender or subject

The query string passed to GmailApp.search() accepts the same operators as the Gmail search bar. To log only emails from a specific domain, change the query to '-label:Logged in:inbox from:@example.com'. To catch a subject keyword, append 'subject:invoice'. You can combine them freely: '-label:Logged in:inbox from:@stripe.com subject:receipt'.

getPlainBody().substring(0, 500) caps the body column at 500 characters. Sheets cells can hold up to 50,000 characters, but long bodies make the sheet slow and hard to read. Adjust the cap to match your use case — for parsing structured content like order IDs embedded in a body, 200 characters is often enough. For archival purposes, bump it to 2000.

If you need the full body reliably, store message IDs instead: log msg.getId() to the sheet, then use a second script or a Sheets formula to fetch content on demand. That pattern keeps the logging pass fast and avoids the occasional 6-minute execution timeout Apps Script imposes on trigger-fired functions when a large inbox produces hundreds of rows in one run.

Want a custom version?

Describe your sheet and the rule you want. Gnaw writes the Apps Script — fields, triggers, edge cases — in one shot.

FAQ

4 questions
Will this log emails that arrived before I set up the script?
Yes. The first run processes every thread in your inbox that does not have the Logged label, including old mail. If you only want new mail going forward, manually apply the Logged label to all existing inbox threads before the first trigger fires, or add a date filter to the search query like 'after:2026/06/05 -label:Logged in:inbox'.
The script ran but the sheet is empty — what went wrong?
The most common cause is a tab name mismatch. getSheetByName('Emails') returns null if the tab is named 'Sheet1' or 'emails' (case-sensitive). Check the tab name exactly. A null sheet will throw 'Cannot call method appendRow of null' in the Apps Script execution log — open View > Executions to see the error.
Can I log emails from a label instead of the full inbox?
Yes. Replace 'in:inbox' with 'label:your-label-name' in the search string. Combine it with the exclusion: '-label:Logged label:your-label-name'. This is useful when you have a Gmail filter already routing specific senders into a folder and you only want to log that subset.
How do I avoid hitting the Gmail API quota?
Apps Script accounts get 20,000 Gmail read calls per day under the standard quota. Each call to getMessages(), getFrom(), getSubject(), and getPlainBody() counts separately. A single thread logged costs roughly 4 read calls. At 500 threads per trigger run and a 15-minute interval, you will stay well under quota for personal or small-team use. If you are processing thousands of threads, batch the reads by fetching all messages at once with getMessages() before the loop rather than inside it.
// one good script a week

Get a working Apps Script snippet in your inbox, weekly.