Skip to content

Instantly share code, notes, and snippets.

@tomshaw
Created August 21, 2025 05:14
Show Gist options
  • Save tomshaw/88a8ee6e9133364258ad97c81e410cf7 to your computer and use it in GitHub Desktop.
Save tomshaw/88a8ee6e9133364258ad97c81e410cf7 to your computer and use it in GitHub Desktop.
Google Apps Script that saves Gmail messages from a specific sender into a Google Doc. It supports optional date filters, attachment saving to Google Drive, and prevents duplicates by labeling threads once processed.
/**
* Save emails from a specific sender to a Google Doc (thread-level dedupe).
* - Uses a Gmail label on the THREAD to avoid duplicates on subsequent runs.
* - Optionally saves attachments to Drive and links them in the Doc.
*
* Scopes prompted on first run:
* GmailApp, DocumentApp, DriveApp (if INCLUDE_ATTACHMENTS = true)
*/
/* ========================== CONFIG ========================== */
const CONFIG = {
SENDER_EMAIL: '[email protected]', // REQUIRED
DOC_NAME: 'Saved Emails — [email protected]', // Created if missing
AFTER: '2025/01/01', // 'YYYY/MM/DD' or '' to disable
BEFORE: '', // 'YYYY/MM/DD' or '' to disable
EXTRA_QUERY: '', // e.g., 'has:attachment -in:drafts'
MAX_THREADS: 150, // safety cap per run
INCLUDE_ATTACHMENTS: false, // save attachments & link them
ATTACHMENTS_FOLDER_NAME: 'Email Attachments — [email protected]',
DEDUP_LABEL_NAME: 'SavedToDoc', // label used on THREADS
};
/* ============================================================ */
/**
* Entry point.
*/
function runSaveEmailsToDoc() {
const docId = ensureDocExists_(CONFIG.DOC_NAME);
const dedupLabel = ensureLabel_(CONFIG.DEDUP_LABEL_NAME);
const attachmentsFolder = CONFIG.INCLUDE_ATTACHMENTS
? ensureFolder_(CONFIG.ATTACHMENTS_FOLDER_NAME)
: null;
const query = buildQuery_({
from: CONFIG.SENDER_EMAIL,
after: CONFIG.AFTER,
before: CONFIG.BEFORE,
extra: CONFIG.EXTRA_QUERY,
// NOTE: Exclude threads already labeled
excludeLabel: CONFIG.DEDUP_LABEL_NAME,
});
const threads = GmailApp.search(query, 0, CONFIG.MAX_THREADS);
if (!threads.length) {
logToDoc_(docId, `No new threads found for query:\n${query}`);
return;
}
const doc = DocumentApp.openById(docId);
const body = doc.getBody();
// newest-first to keep doc reading top→bottom chronologically across runs
threads.forEach((thread) => {
// Safety: skip if somehow already labeled
const threadHasLabel = thread.getLabels().some(l => l.getName() === CONFIG.DEDUP_LABEL_NAME);
if (threadHasLabel) return;
const messages = thread.getMessages();
messages.forEach((msg) => {
appendMessageToDoc_(body, msg, {
includeAttachments: CONFIG.INCLUDE_ATTACHMENTS,
attachmentsFolder,
});
Utilities.sleep(30); // gentle on quotas for large exports
});
// Mark the whole thread as saved
thread.addLabel(dedupLabel);
Utilities.sleep(50);
});
doc.saveAndClose();
}
/* ======================== HELPERS =========================== */
function buildQuery_({ from, after, before, extra, excludeLabel }) {
const parts = [];
if (from) parts.push(`from:${quoteGmail_(from)}`);
if (after) parts.push(`after:${quoteGmail_(after)}`);
if (before) parts.push(`before:${quoteGmail_(before)}`);
if (extra) parts.push(extra.trim());
if (excludeLabel) parts.push(`-label:${quoteGmail_(excludeLabel)}`);
parts.push('-in:drafts -in:spam -in:trash');
return parts.filter(Boolean).join(' ');
}
function quoteGmail_(val) {
return /[\s:"']/g.test(val) ? `"${val.replace(/"/g, '\\"')}"` : val;
}
function ensureDocExists_(name) {
const files = DriveApp.getFilesByName(name);
if (files.hasNext()) return files.next().getId();
const doc = DocumentApp.create(name);
const body = doc.getBody();
body.appendParagraph(name).setHeading(DocumentApp.ParagraphHeading.TITLE);
body.appendHorizontalRule();
doc.saveAndClose();
return doc.getId();
}
function ensureLabel_(name) {
return GmailApp.getUserLabelByName(name) || GmailApp.createLabel(name);
}
function ensureFolder_(name) {
const iter = DriveApp.getFoldersByName(name);
return iter.hasNext() ? iter.next() : DriveApp.createFolder(name);
}
function appendMessageToDoc_(body, msg, opts) {
const subject = msg.getSubject() || '(no subject)';
const from = msg.getFrom();
const to = msg.getTo() || '';
const cc = msg.getCc() || '';
const date = msg.getDate();
const permalink = 'https://mail.google.com/mail/u/0/#all/' + msg.getId();
const header = [
['Subject', subject],
['From', from],
['To', to],
];
if (cc) header.push(['Cc', cc]);
header.push(['Date', Utilities.formatDate(date, Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm:ss Z')]);
header.push(['Gmail Link', permalink]);
const table = body.appendTable(header);
table.setBorderWidth(0.5);
for (let r = 0; r < table.getNumRows(); r++) {
table.getRow(r).getCell(0).editAsText().setBold(true);
}
body.appendParagraph('').setSpacingAfter(0);
const plain = (msg.getPlainBody && msg.getPlainBody()) || '';
if (plain.trim()) {
body.appendParagraph('Message:').setHeading(DocumentApp.ParagraphHeading.HEADING4);
body.appendParagraph(plain);
} else {
// Fallback if only HTML
const html = (msg.getBody && msg.getBody()) || '';
body.appendParagraph('Message (HTML, simplified):').setHeading(DocumentApp.ParagraphHeading.HEADING4);
body.appendParagraph(stripHtml_(html));
}
if (opts && opts.includeAttachments) {
const attachments = msg.getAttachments({ includeInlineImages: false, includeAttachments: true }) || [];
if (attachments.length && opts.attachmentsFolder) {
body.appendParagraph('').setSpacingAfter(0);
body.appendParagraph('Attachments:').setHeading(DocumentApp.ParagraphHeading.HEADING4);
attachments.forEach((blob) => {
const file = opts.attachmentsFolder.createFile(blob);
body.appendParagraph(`${file.getName()} — ${file.getUrl()}`);
});
}
}
body.appendHorizontalRule();
}
function stripHtml_(html) {
// very light cleanup; Docs will render it as text
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
}
function logToDoc_(docId, message) {
const doc = DocumentApp.openById(docId);
doc.getBody().appendParagraph(`[${new Date().toISOString()}] ${message}`);
doc.saveAndClose();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment