Created
August 21, 2025 05:14
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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(/ /g, ' ') | |
.replace(/&/g, '&') | |
.replace(/</g, '<') | |
.replace(/>/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