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 columns

Turn a table into JSON string records

Preparing rows for a script? Use unique header names as JSON fields while keeping every value a string, including leading zeros and empty cells. Paste literal tab-separated cells; quoted multiline exports need a CSV parser first.

Convert a table to JSON strings

Join two exports by exact keys

Choose a key column in each table and preview matching rows, including every duplicate match. An optional left join keeps unmatched rows with blank right-hand cells. Inspect source row positions before importing; native spreadsheet lookup or join remains an alternative.

Combining exports with primary and fallback fields? Pick the first nonempty value in your chosen column order, retaining whitespace and every original row.

Preview first nonempty values across columnsPreview a literal keyed table join

Preview literal text replacements before import

Replace exact, case-sensitive text in columns you choose. Review changed cell positions and copy the result; unselected columns and an optional header stay unchanged. This does not run formulas or write to your Sheet. Native find and replace remains an option inside Sheets.

Preview literal find and replace

Total whole-number quantities by group

Preview exact totals from a pasted tab-separated table using explicit group and quantity columns. Group names match literally, including case and spaces. Quantities must be signed whole-number text; decimals, currency and separators are rejected. Review counts and original row positions before copying. Native pivot tables or SUMIF are alternatives for totals inside a Sheet.

Preview grouped integer totals

Reconciling two exports before totaling? Compare complete-row membership and duplicate counts first. This compares exact rows without guessing a key or changing either table.

Compare whole-row membership before importCount 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 duplicates

Before combining two exported tables, preview an exact-key lookup using explicit key and return columns. Duplicate lookup keys stop the preview; unmatched left rows stay visible. No Sheet is changed.

Preview an exact-key lookup

Need to review row order before importing? Preview a stable literal text sort using an explicit key column, header choice and direction. Tied rows keep their original order; numbers remain text. Use native spreadsheet sort for in-place sorting.

Grouped exports with repeated values omitted? Preview fill-down in explicitly chosen columns. Only exactly empty cells fill from preceding literal data; leading blanks remain empty. Review donor positions before copying, or use native spreadsheet fill-down in place.

Need combined text from separate columns? Preview an appended column using your chosen order and literal separator while retaining the original cells. Native spreadsheet concatenation is an alternative.

Combining exports with the same columns? Preview a literal stack of two tables. Headers must match exactly, duplicate rows remain, and original table and row positions stay visible. Native spreadsheet copy/append is an alternative.

Preview a literal table stack

Need month or metric columns as rows? Preview an explicit unpivot, preserving identifier columns and blank values. Review original cell positions before copying; native spreadsheet unpivot is an alternative.

Preview a literal unpivotPreview a literal column joinPreview a literal column splitPreview blank-cell fill-downPreview a stable text sortCompare 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

Is the attachment a recipient list for expected messages?

Use the complete mail review job for a UTF-8 CSV with an email header and 1–25 recipient rows. Import the file locally, add your subject and body templates, then check every recipient and rendered message before creating the messages manually in Gmail. Duplicate or malformed addresses and missing template fields must be resolved first.

This prepares a review packet; it does not fetch attachments, create Gmail drafts or send mail. Only use it for messages the recipients expect. Other attachment types belong in the CSV preview below.

Review recipient CSV and personalized messages

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.