Greasy Fork is available in English.
Adds a "Copy Thread" button in Gmail next to Reply/Forward that copies the entire email thread to the clipboard in Markdown format.
// ==UserScript==
// @name Gmail: Copy Thread as Markdown
// @namespace https://greasyfork.org/
// @version 1.0.0
// @description Adds a "Copy Thread" button in Gmail next to Reply/Forward that copies the entire email thread to the clipboard in Markdown format.
// @author groundcat
// @match https://mail.google.com/mail/*
// @grant none
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const BUTTON_ID = 'gmailCopyThreadMdBtn';
const BUTTON_TEXT = '📋 Copy Thread';
// ── Helpers ──────────────────────────────────────────────────────────────
function getSubject() {
return document.querySelector('.hP')?.textContent.trim() || 'Email Thread';
}
function expandCollapsedMessages() {
return new Promise(resolve => {
const collapsed = document.querySelectorAll('.gs.gt');
if (!collapsed.length) { resolve(); return; }
collapsed.forEach(m => m.click());
// Give Gmail time to render the newly expanded messages
setTimeout(resolve, 900);
});
}
function collectMessages() {
const messages = [];
document.querySelectorAll('.gs').forEach(m => {
// Only process expanded messages — they have a body element
const bodyEl = m.querySelector('.ii.gt') || m.querySelector('.a3s');
if (!bodyEl) return;
const fromEl = m.querySelector('.gD');
const dateEl = m.querySelector('.g3');
const g2Els = m.querySelectorAll('.g2');
const from = fromEl?.textContent.trim() || 'Unknown';
const fromEmail = fromEl?.getAttribute('email') || '';
const date = dateEl?.getAttribute('title') || dateEl?.textContent.trim() || '';
const to = [...g2Els].map(el => {
const name = el.textContent.trim();
const email = el.getAttribute('email');
return (email && email !== name) ? `${name} <${email}>` : name;
}).filter(Boolean).join(', ');
// innerText naturally excludes the collapsed quoted-text block (.h5 { display:none })
messages.push({ from, fromEmail, date, to, body: bodyEl.innerText.trim() });
});
return messages;
}
function toMarkdown(messages, subject) {
const out = ['# ' + subject, ''];
messages.forEach(msg => {
out.push('---', '');
out.push('**From:** ' + (msg.fromEmail ? `${msg.from} <${msg.fromEmail}>` : msg.from));
if (msg.to) out.push('**To:** ' + msg.to);
if (msg.date) out.push('**Date:** ' + msg.date);
out.push('');
out.push(...msg.body.split('\n'));
out.push('');
});
return out.join('\n');
}
// ── Button action ─────────────────────────────────────────────────────────
async function handleClick() {
const btn = document.getElementById(BUTTON_ID);
if (!btn || btn.dataset.busy === '1') return;
btn.dataset.busy = '1';
const setStyle = (text, color, bg, border) => {
btn.textContent = text;
btn.style.color = color || '';
btn.style.background = bg || '';
btn.style.borderColor = border || '';
btn.style.opacity = '1';
};
setStyle('⏳ Loading…');
btn.style.opacity = '0.75';
await expandCollapsedMessages();
const markdown = toMarkdown(collectMessages(), getSubject());
try {
await navigator.clipboard.writeText(markdown);
setStyle('✅ Copied!', '#1a73e8', 'rgba(26,115,232,0.08)', '#1a73e8');
} catch (_) {
// Fallback for environments where the Async Clipboard API is restricted
const ta = Object.assign(document.createElement('textarea'), {
value: markdown,
style: 'position:fixed;opacity:0;top:0;left:0'
});
document.body.appendChild(ta);
ta.focus(); ta.select();
try {
document.execCommand('copy');
setStyle('✅ Copied!', '#1a73e8', 'rgba(26,115,232,0.08)', '#1a73e8');
} catch (_2) {
setStyle('❌ Failed', 'red');
}
document.body.removeChild(ta);
}
setTimeout(() => {
setStyle(BUTTON_TEXT);
delete btn.dataset.busy;
}, 2500);
}
// ── Button creation ───────────────────────────────────────────────────────
function createButton() {
const btn = document.createElement('span');
btn.id = BUTTON_ID;
btn.setAttribute('role', 'link');
btn.tabIndex = 0;
btn.textContent = BUTTON_TEXT;
btn.className = 'ams'; // shares Gmail's base button class
btn.style.cssText = [
'display:inline-flex',
'align-items:center',
'border:1px solid rgb(116,119,117)',
'border-radius:18px',
'color:rgb(68,71,70)',
'cursor:pointer',
'font-family:"Google Sans",Roboto,Helvetica,Arial,sans-serif',
'font-size:14px',
'margin:0 8px 0 0',
'padding:6px 16px 6px 12px',
'user-select:none',
'white-space:nowrap',
'transition:background .15s,border-color .15s,color .15s',
].join(';');
btn.addEventListener('click', handleClick);
btn.addEventListener('keypress', e => { if (e.key === 'Enter' || e.key === ' ') handleClick(); });
btn.addEventListener('mouseover', () => { if (!btn.dataset.busy) btn.style.background = 'rgba(68,71,70,0.08)'; });
btn.addEventListener('mouseout', () => { if (!btn.dataset.busy) btn.style.background = ''; });
return btn;
}
// ── Injection ─────────────────────────────────────────────────────────────
function findVisibleAmn() {
// Gmail renders several .amn containers; only one is actually on-screen
for (const amn of document.querySelectorAll('.amn')) {
const r = amn.getBoundingClientRect();
if (r.width > 0 && r.height > 0) return amn;
}
return null;
}
function tryInject() {
if (document.getElementById(BUTTON_ID)) return; // already present
const amn = findVisibleAmn();
if (!amn) return;
// Confirm this .amn is the Reply/Forward toolbar (not some other widget)
// bkI = Reply all, bkH = Reply, bkG = Forward
if (!amn.querySelector('span.ams.bkG, span.ams.bkH, span.ams.bkI')) return;
const btn = createButton();
const forwardBtn = amn.querySelector('span.ams.bkG'); // Forward button
// Insert right after Forward (or after Share-in-Chat if present, since
// Share-in-Chat would be the next sibling)
if (forwardBtn?.nextSibling) {
amn.insertBefore(btn, forwardBtn.nextSibling);
} else {
amn.appendChild(btn);
}
}
// ── SPA observer ──────────────────────────────────────────────────────────
// Gmail is a single-page app: navigating between threads swaps the DOM
// without a full page reload, so we watch for DOM mutations.
const observer = new MutationObserver(tryInject);
observer.observe(document.body, { childList: true, subtree: true });
// Also run once in case a thread is already open on load
tryInject();
})();