Skip to content
// Gmail · Sheets · Apps Script

Parse CSV attachments from Gmail into Google Sheets.

A Google Apps Script that finds CSV attachments in Gmail, parses them with Utilities.parseCsv (handling quoted newlines and Windows-1252 encoding), and writes rows directly into a target Sheet.

“I need a script that pulls CSV files attached to specific Gmail messages and writes their rows into a Google Sheet automatically, without corrupting quoted fields that contain commas or line breaks.”

The script

copy · paste · trigger
importGmailCsv.gs
Apps Script
// importGmailCsv.gs — search Gmail, parse first CSV attachment, append to Sheet
function importGmailCsv() {
  var QUERY = 'subject:"weekly export" has:attachment newer_than:7d';
  var SHEET_ID = 'YOUR_SPREADSHEET_ID';
  var SHEET_NAME = 'Imports';
  var CHARSET = 'windows-1252';

  var threads = GmailApp.search(QUERY, 0, 10);
  var sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName(SHEET_NAME);

  for (var t = 0; t < threads.length; t++) {
    var messages = threads[t].getMessages();
    for (var m = 0; m < messages.length; m++) {
      var attachments = messages[m].getAttachments();
      for (var a = 0; a < attachments.length; a++) {
        var att = attachments[a];
        if (att.getName().slice(-4).toLowerCase() !== '.csv') continue;
        var csv = att.getDataAsString(CHARSET);
        var rows = Utilities.parseCsv(csv);
        sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, rows[0].length)
             .setValues(rows);
      }
    }
  }
}

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

Walkthrough

Why split(',') will eventually wreck your data

CSV is deceptively simple until a field contains a comma or a newline. RFC 4180 says those fields are wrapped in double-quotes, and a hand-rolled split(',') has no idea those quotes exist. The first time I hit this was an address export where 'Suite 400, Floor 3' lived in a single column — split blew it into two columns and shifted every subsequent field one position to the right, silently.

Utilities.parseCsv is the correct tool. It is Apps Script's built-in RFC 4180 parser: it handles quoted commas, quoted newlines, and escaped quotes (two consecutive double-quotes inside a quoted field). You hand it a string and it returns a two-dimensional array of strings. No regex, no edge-case wrangling.

Encoding: the silent damage Excel exports cause

getDataAsString() defaults to UTF-8. That is correct for CSVs generated by anything modern. Excel on Windows, however, still defaults to Windows-1252 (also called CP-1252) when you 'Save As CSV'. Windows-1252 encodes characters like the euro sign (€), curly quotes, and accented letters differently from UTF-8. If you call getDataAsString() without a charset argument on a Windows-1252 file, those bytes decode as garbage or throw an exception entirely.

The fix is one argument: att.getDataAsString('windows-1252'). If you control the export and know it is UTF-8, you can drop that argument or pass 'UTF-8' explicitly. When the source is an external vendor and you are not sure, request a sample file, run Utilities.base64Encode on a few bytes around a suspect character, and compare against a Windows-1252 byte table. It is a ten-minute one-time check.

A note on the BOM (byte-order mark): some UTF-8 exports prepend three invisible bytes (EF BB BF) to signal the encoding. Utilities.parseCsv will include them in the first cell value as a garbage prefix. If your header row's first column name looks wrong in the sheet, strip the BOM manually: csv = csv.replace('', '').

Scoping the Gmail search and avoiding re-imports

GmailApp.search accepts the same query syntax as the Gmail search box. newer_than:7d limits the thread scan to the last week; without it the script re-reads your entire inbox on every run. Add has:attachment to skip threads with no attachments at the query level, not in the loop.

The script as written appends every matching attachment on every run, which means re-importing the same file if you run it twice in a week. The practical guard is to label processed threads: call thread.addLabel(GmailApp.getUserLabelByName('csv-imported')) after processing, then add -label:csv-imported to your QUERY string. One label, no database, no duplicates.

GmailApp.search returns at most 500 threads per call. The second and third arguments (start, max) let you paginate: start at 0, max at 10 is conservative for a weekly job. If the volume is higher, loop with an increasing start offset until the returned array is shorter than max.

Review original identifiers before importing

Leading zeros and long digit strings deserve a check against the original attachment before import. Paste one original identifier per line into the free browser check. It flags possible conversion risks; it cannot recover digits or zeros that have already been lost. Do not paste a whole CSV file into the identifier checker.

Check original identifiers

Match incoming columns to your expected headers

Compare one expected header record with the incoming file’s header. See which names moved, are missing or are unexpected before connecting columns in a script. Names match exactly, including case and spaces; the check never guesses or rearranges your data.

Map CSV header order

Comparing two pasted tab-separated tables? Preview changed cells and added or removed IDs using an explicit key column, before deciding what to update.

Select and reorder table columns

Need rows to become columns? Preview a literal transpose of a pasted tab-separated table, including its header.

Transpose table rows and columnsCount exact values in a columnPreview exact-value matching rows

Check empty required cells in a literal tab-separated table by choosing column numbers yourself. This does not infer business rules or edit a Sheet.

Check required fields

Need this check in a local script? Download the same required-field checker with a runnable example. Select required columns yourself; the module does not infer business rules.

Repeated complete rows? Review exact matches and their original positions before deciding what to keep. Every row, including any header, is checked; nothing is deleted.

Review whole-row duplicatesCompare table snapshots

Preview quoted CSV before importing

Choose the delimiter and preview raw CSV records, including quoted commas, doubled quotes and embedded newlines. The free check keeps values as text and shows original line references, blank or repeated headers and uneven field counts. It does not import data, run formulas or certify a safe import.

Preview quoted CSV records

Check the parsed table before writing it

After parsing the CSV, review a literal tab-separated table for uneven rows, missing headers and formula-like prefixes. These checks do not parse raw CSV: commas, quoted tabs and multiline cells need your CSV parser first. The prefix review can flag intentional formulas or negative numbers and does not certify safe imports. Nothing is imported or changed.

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
My CSV has a header row and I don't want it repeated in the sheet on every run. How do I skip it?
Slice the rows array before writing: pass rows.slice(1) to setValues instead of rows. If you also need to write the header once when the sheet is empty, check sheet.getLastRow() === 0 and write rows[0] separately on the first run.
Utilities.parseCsv throws 'Cannot call method parseCsv of undefined'. What is wrong?
This happens when the attachment's getDataAsString returns an empty string, usually because the charset argument is wrong and the decode failed silently in older Apps Script runtimes. Log att.getBytes().length before calling getDataAsString to confirm the attachment has content, then verify the charset. On newer runtimes, a bad charset will throw a more descriptive error instead.
The script times out when there are many messages. What is the limit?
Apps Script imposes a 6-minute execution ceiling on triggered functions and 30 seconds on manually run functions. If you are processing large batches, break the work across runs: write the last processed thread index to PropertiesService (PropertiesService.getScriptProperties().setProperty('lastIndex', t)), read it at the start of the next run, and pick up from there. This is sometimes called a continuation pattern.
Can I run this on a schedule without opening the spreadsheet manually?
Yes. In the Apps Script editor go to Triggers (clock icon on the left sidebar), add a time-driven trigger pointing at importGmailCsv, and set the interval. Hourly or daily is typical for email exports. The trigger runs under your account so it inherits your Gmail and Sheets permissions — no extra OAuth setup needed beyond the initial manual authorization run.
// one good script a week

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