Gmail: Copy Thread as Markdown

Adds a "Copy Thread" button in Gmail next to Reply/Forward that copies the entire email thread to the clipboard in Markdown format.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

// ==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();
})();