How to Email Each Row’s PDF as an Attachment from Google Sheets (2026)

You have a sheet with one row per client, student or employee, and a PDF for each of them sitting in Google Drive: an invoice, a certificate, a letter, a report. Now each one has to go to the right person. Doing it by hand means opening Gmail, attaching the file, typing the name and hoping you didn't attach Ben's invoice to Ana's email. This guide does it from the sheet: an Email column, a PDF link column, a subject and message with {{placeholders}}, and a free Apps Script that sends each PDF to its row's address, stamps the date it went out, and never sends the same row twice.

It assumes the PDFs already exist. To make them from a Google Docs template first, see a PDF or Doc for each row, or the invoices and certificates guide. This is not a newsletter tool: send only to people who expect the document, such as a client getting their own invoice.

Step 1: three columns the script needs

Row 1 is the header row. The script finds columns by name, so the order doesn't matter, and every other column can be used in the message:

NameEmailDocumentPDF linkSent on
Ana Diazana@example.comInvoice 1001https://drive.google.com/file/d/…/view
Ben Itoben@example.com, accounts@example.comInvoice 1002https://drive.google.com/file/d/…/view

The files don't need to be shared with anyone. The script attaches a copy of the file, so the recipient doesn't need Drive access, and your sharing settings stay as they are.

Step 2: the subject and message

At the top of the script, edit SUBJECT and BODY. Any column header in double braces is replaced by that row's value, exactly as the sheet displays it, plus {{Today}} for today's date. In BODY, \n starts a new line:

const SUBJECT = '{{Document}} from Acme Co.';
const BODY = 'Hi {{Name}},\n\nAttached is {{Document}}, due {{Due date}}.\n\nThanks,\nAcme Co.';

Placeholders must match the header exactly, including spaces and capitals. If the subject or message uses one with no matching column, such as {{Invoice #}} when the column is “Invoice no.”, the script stops before sending anything and tells you which one. Set SENDER_NAME to the name people should see in their inbox, and REPLY_TO if replies should go to another address.

Step 3: install the script

In the sheet, open Extensions › Apps Script, replace the sample code with the script below, edit the settings at the top, and save. Reload the sheet; a Send PDFs menu appears. The first time you use it, Google asks you to approve the permissions.

// Email each row's PDF as an attachment, from Google Sheets. Free script from loomwise.app (Nov 2026).
// Needs columns named Email, PDF link and Sent on (any order). Any other header works as a {{placeholder}}.
const SUBJECT = 'Your document from Acme Co.';
const BODY = 'Hi {{Name}},\n\nYour document is attached as a PDF.\n\nThanks,\nAcme Co.';
const SENDER_NAME = 'Acme Co.';  // the From name people see; '' uses your account name
const REPLY_TO = '';             // optional reply-to address
const CC_COLUMN = '';            // optional header of a column with a CC address, e.g. 'CC'
const MAX_PER_RUN = 50;          // run again to send the rest
const MAX_MB = 20;               // Gmail's limit is 25 MB per message, attachments included

function onOpen() {
  SpreadsheetApp.getUi().createMenu('Send PDFs')
    .addItem('Send me a test of the first ready row', 'sendTestToMe')
    .addItem('Send PDFs to rows not yet sent', 'sendUnsent')
    .addToUi();
}

function sendTestToMe() { return run_(true); }
function sendUnsent() { return run_(false); }

function run_(test) {
  const lock = LockService.getDocumentLock();
  if (!lock.tryLock(30000)) throw new Error('Another send is already running. Try again in a minute.');
  try {
    const ss = SpreadsheetApp.getActive(), sh = ss.getActiveSheet();
    const rows = sh.getDataRange().getDisplayValues(), head = rows[0].map(h => String(h).trim());
    const col = name => { const i = head.indexOf(name); if (i < 0) throw new Error('This tab is missing the column "' + name + '".'); return i; };
    const cEmail = col('Email'), cPdf = col('PDF link'), cSent = col('Sent on');
    const cCc = CC_COLUMN ? col(CC_COLUMN) : -1;
    const today = Utilities.formatDate(new Date(), ss.getSpreadsheetTimeZone(), 'MMMM d, yyyy');
    const unknown = missing_(SUBJECT + '\n' + BODY, head);
    if (unknown.length) throw new Error('No column for: ' + unknown.map(k => '{{' + k + '}}').join(', ') + '. Nothing was sent.');
    const ready = [];
    for (let r = 1; r < rows.length; r++) {
      if (rows[r][cEmail].trim() && rows[r][cPdf].trim() && !rows[r][cSent].trim()) ready.push(r);
    }
    if (!ready.length) { ss.toast('No rows are ready (each needs an Email, a PDF link and an empty Sent on).'); return { sent: 0, skipped: 0, left: 0 }; }
    const me = Session.getEffectiveUser().getEmail();
    if (test) {
      const r = ready[0], v = values_(head, rows[r], today);
      const att = attachment_(rows[r][cPdf]);
      if (att.error) throw new Error('Row ' + (r + 1) + ': ' + att.error);
      MailApp.sendEmail(mail_(me, '', '[TEST to ' + rows[r][cEmail].trim() + '] ' + fill_(SUBJECT, v), fill_(BODY, v), att.blob));
      ss.toast('Test for row ' + (r + 1) + ' sent to ' + me + '. Nothing was marked as sent.');
      return { sent: 1, skipped: 0, left: ready.length, test: true };
    }
    const ui = SpreadsheetApp.getUi();
    const n = Math.min(ready.length, MAX_PER_RUN);
    if (ui.alert('Send PDFs', 'Email ' + n + ' PDF(s) now? Emails cannot be unsent.', ui.ButtonSet.YES_NO) !== ui.Button.YES) return { sent: 0, skipped: 0, left: ready.length, cancelled: true };
    let quota = MailApp.getRemainingDailyQuota(), sent = 0, skipped = 0, done = 0;
    for (const r of ready) {
      if (sent >= MAX_PER_RUN) break;
      const to = rows[r][cEmail].trim(), cc = cCc >= 0 ? rows[r][cCc].trim() : '';
      const mark = text => { sh.getRange(r + 1, cSent + 1).setValue(text); SpreadsheetApp.flush(); };
      done++;
      if (!validList_(to) || (cc && !validList_(cc))) { mark('Skipped: check the email address'); skipped++; continue; }
      const need = to.split(',').length + (cc ? cc.split(',').length : 0);
      if (need > quota) { done--; break; }
      const att = attachment_(rows[r][cPdf]);
      if (att.error) { mark('Skipped: ' + att.error); skipped++; continue; }
      const v = values_(head, rows[r], today);
      MailApp.sendEmail(mail_(to, cc, fill_(SUBJECT, v), fill_(BODY, v), att.blob));
      mark(new Date()); quota -= need; sent++;
    }
    const left = ready.length - done;
    ss.toast('Sent ' + sent + '.' + (skipped ? ' Skipped ' + skipped + ' (reason in Sent on; clear the cell to retry).' : '') +
      (left ? ' ' + left + ' left: ' + (quota < 1 || sent < MAX_PER_RUN ? 'daily email quota reached, run again tomorrow.' : 'run again to send them.') : ''));
    return { sent: sent, skipped: skipped, left: left };
  } finally { lock.releaseLock(); }
}

function values_(head, row, today) {
  const v = { Today: today };
  head.forEach((h, i) => { if (h) v[h] = String(row[i]); });
  return v;
}

// One pass, literal values: a $ or {{ }} inside a cell is copied as typed.
function fill_(text, v) {
  return text.replace(/\{\{([^{}]+)\}\}/g, (m, k) => Object.prototype.hasOwnProperty.call(v, k) ? v[k] : m);
}

function missing_(text, head) {
  const out = [];
  (text.match(/\{\{([^{}]+)\}\}/g) || []).forEach(m => {
    const k = m.slice(2, -2);
    if (k !== 'Today' && head.indexOf(k) < 0 && out.indexOf(k) < 0) out.push(k);
  });
  return out;
}

function validList_(s) {
  return s.split(',').every(a => /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/.test(a.trim()));
}

function fileId_(link) {
  const s = String(link).trim();
  const m = s.match(/\/d\/([-\w]{20,})/) || s.match(/[?&]id=([-\w]{20,})/) || s.match(/^([-\w]{20,})$/);
  return m ? m[1] : '';
}

function attachment_(link) {
  const id = fileId_(link);
  if (!id) return { error: 'PDF link is not a Google Drive file link' };
  let file;
  try { file = DriveApp.getFileById(id); } catch (e) { return { error: 'cannot open the file (deleted, or not shared with you)' }; }
  const blob = file.getMimeType() === MimeType.PDF ? file.getBlob() : file.getAs(MimeType.PDF);
  if (blob.getBytes().length > MAX_MB * 1024 * 1024) return { error: 'file is over ' + MAX_MB + ' MB' };
  let name = file.getName();
  if (!/\.pdf$/i.test(name)) name += '.pdf';
  return { blob: blob.setName(name) };
}

function mail_(to, cc, subject, body, blob) {
  const m = { to: to, subject: subject, body: body, attachments: [blob] };
  if (cc) m.cc = cc;
  if (SENDER_NAME) m.name = SENDER_NAME;
  if (REPLY_TO) m.replyTo = REPLY_TO;
  return m;
}

Step 4: test on yourself, then send

  1. Open the tab with your rows and choose Send PDFs › Send me a test of the first ready row. It emails the first row that's ready to you, with the real attachment and message and the intended recipient in the subject, and marks nothing as sent. Open the attachment and read the message.
  2. When it looks right, choose Send PDFs › Send PDFs to rows not yet sent. It asks you to confirm how many it will send; emails can't be unsent.
  3. Check the Sent on column. Each sent row has a date. Rows that say “Skipped: …” need fixing; correct the problem, clear the cell, and run it again.

Next week, add new rows with their PDF links and run it again. Only rows with an empty Sent on are emailed.

What the script does, and why

Tips and caveats

Need the PDFs first? SheetWeave (disclosure: we make it) is a Google Sheets add-on that fills a Google Docs template from each row and saves a Doc, a PDF, or both in a Drive folder you choose, writing the links back to the row. Rename that link column to “PDF link” and this script can send them. SheetWeave itself doesn't send email: it uses narrow scopes and can't read or send your Gmail, and your sheet data stays inside your Google account. 14-day free trial, no credit card required.
See SheetWeave

Related Google Sheets guides

Instructions refer to Google Sheets, Google Drive, Gmail and Apps Script as they worked on November 13, 2026; quota figures are from Google's Apps Script quotas page, updated September 3, 2026. Not affiliated with Google or any other vendor named here. Google Sheets, Google Drive and Gmail are trademarks of Google LLC. Features and limits change, so verify in your own account. Questions or corrections: hello@loomwise.app.