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:
| Name | Document | PDF link | Sent on | |
|---|---|---|---|---|
| Ana Diaz | ana@example.com | Invoice 1001 | https://drive.google.com/file/d/…/view | |
| Ben Ito | ben@example.com, accounts@example.com | Invoice 1002 | https://drive.google.com/file/d/…/view |
- Email: the recipient. Several addresses can share a cell, separated by commas.
- PDF link: the file's Google Drive link. In Drive, right-click the file, choose Share › Copy link and paste it in. Links to a Google Doc work too; the script attaches it as a PDF.
- Sent on: leave it empty. The script writes the date and time each email went out, or the reason it skipped a row.
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
- 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.
- 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.
- 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
- Nothing goes out twice. A row is sent only when it has an Email and a PDF link and its Sent on cell is empty. The date is written right after each email goes out, so if the run stops halfway, the next run carries on where it left off. To resend one row on purpose, clear its Sent on cell.
- Problems are written next to the row. A malformed address, a link that isn't a Drive file, a file that was deleted or isn't shared with you, or a file too big for Gmail is skipped, with the reason in Sent on, and the rest still go out.
- Values are pasted literally. A name containing
$1or braces is copied as typed and is never treated as another placeholder. - It respects Gmail's limits. Before each email it checks how many recipients you have left today with
MailApp.getRemainingDailyQuota()and stops cleanly instead of failing partway. Google's Apps Script quotas page lists 100 email recipients a day for consumer Gmail accounts and 1,500 for Google Workspace accounts; every address in To and CC counts. Messages, attachments included, are limited to 25 MB, so the script skips files over 20 MB. - Runs in batches. It sends at most 50 per run (change
MAX_PER_RUN) so a run stays inside Apps Script's six-minute limit; the message at the end tells you how many are left. - Optional CC. Add a column such as “CC” (for example a manager or accounts address) and set
CC_COLUMN = 'CC'.
Tips and caveats
- It sends from your account. Emails come from the Google account that runs the script and appear in your Sent folder. Use a shared or business account if replies should land there.
- Check the right file is linked. The script attaches whatever the link points to. Sort or filter carefully, and spot-check a few rows' links against their names before a big send.
- Permissions. Google will ask to let the script send email as you and to see your Drive files. That's normal for a script you wrote yourself, but it's broad access, so keep the script in a sheet only you or trusted people can edit.
- Sensitive documents. Payslips, grades and medical or HR letters deserve care: double-check addresses, and follow your organization's rules about sending personal data by email.
- Want it automatic? In the Apps Script editor, Triggers can run
sendUnsenton a schedule, but scheduled runs can't show the confirmation box. Only do that after removing the confirmation step and testing thoroughly, because nobody will be watching what goes out. - Reminders instead of documents? For “this is due soon” emails driven by a date column, see invoice payment reminders or email reminders from a date column.
See SheetWeave
Related Google Sheets guides
- How to generate a PDF or Google Doc for each row in Google Sheets
- Best Autocrat alternatives for mail merge to PDF and Docs
- Make invoices or certificates from Google Sheets rows
- Create sales quotes or estimates from Google Sheets
- Generate receipts from Google Sheets rows
- Create purchase orders from Google Sheets
- Make packing slips from Google Sheets orders
- Generate a PDF certificate from a Google Form response
- Issue numbered training completion certificates with expiry dates
- Make a PDF progress report for each student
- Generate offer letters from a Google Sheets hiring tracker
- Email each row’s PDF as an attachment
- Automatic email reminders from a date column
- Contract renewal and expiration alerts from Google Sheets
- Track certificate of insurance (COI) expirations in Google Sheets
- Track employee license and certification expirations
- Track equipment and vehicle maintenance with reminders
- Track warranty expiration dates with reminders
- Birthday and work anniversary reminders from Google Sheets
- Rent due and lease renewal reminders from Google Sheets
- Send invoice payment reminders from Google Sheets
- Highlight overdue tasks and send email reminders
- Follow-up reminders for leads, clients and candidates
- Membership renewal reminders for clubs and associations
- SheetWeave vs Autocrat
- SheetWeave vs Document Studio
- SheetWeave vs RowDocs
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.