code icon Code

Fetch Gmail Thread

Fetch complete thread and reconstruct as readable conversation. Orders chronologically, strips quoted content, extracts participants.

Source Code

const [threadId, includeFullBody = "true"] = process.argv.slice(2);
const includeBodies = includeFullBody !== "false";

if (!threadId) {
  console.error("Error: threadId is required");
  process.exit(1);
}

console.log(`Fetching thread: ${threadId}`);

try {
  const format = includeBodies ? "full" : "metadata";
  const url = `https://gmail.googleapis.com/gmail/v1/users/me/threads/${threadId}?format=${format}`;

  const res = await fetch(url, {
    headers: { Authorization: "Bearer PLACEHOLDER_TOKEN" },
  });

  if (!res.ok) {
    const errorText = await res.text();
    console.error(`Gmail API error: ${res.status}`);
    console.error(errorText);
    throw new Error(`Failed to fetch thread: ${res.status}`);
  }

  const thread = await res.json();

  if (!thread.messages || thread.messages.length === 0) {
    console.log("Thread has no messages");
    console.log(JSON.stringify({ threadId, messages: [] }));
    process.exit(0);
  }

  // Extract participants
  const participants = new Set();
  const messages = [];

  for (const msg of thread.messages) {
    const getHeader = (name) => {
      const header = msg.payload?.headers?.find(
        (h) => h.name.toLowerCase() === name.toLowerCase()
      );
      return header ? header.value : "";
    };

    const from = getHeader("From");
    const to = getHeader("To");
    const cc = getHeader("Cc");

    // Collect participants
    extractEmails(from).forEach((e) => participants.add(e));
    extractEmails(to).forEach((e) => participants.add(e));
    extractEmails(cc).forEach((e) => participants.add(e));

    let body = msg.snippet || "";
    if (includeBodies) {
      body = extractBody(msg.payload);
      body = stripQuotedContent(body);
    }

    messages.push({
      id: msg.id,
      from: from,
      to: to,
      cc: cc || undefined,
      date: getHeader("Date"),
      timestamp: parseInt(msg.internalDate) || null,
      subject: getHeader("Subject"),
      body: body.trim(),
      snippet: msg.snippet,
      labelIds: msg.labelIds || [],
    });
  }

  // Sort by timestamp (chronological)
  messages.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));

  // Compute date range
  const dates = messages.filter((m) => m.timestamp).map((m) => new Date(m.timestamp));
  const dateRange = dates.length > 0
    ? {
        started: dates[0].toISOString(),
        lastMessage: dates[dates.length - 1].toISOString(),
      }
    : null;

  const conversation = {
    threadId: thread.id,
    subject: messages[0]?.subject || "No Subject",
    messageCount: messages.length,
    participants: Array.from(participants),
    dateRange,
    messages: messages.map((m) => ({
      id: m.id,
      from: m.from,
      to: m.to,
      cc: m.cc,
      date: m.date,
      body: m.body,
    })),
  };

  console.log(`\n✓ Thread: ${conversation.subject}`);
  console.log(`  Messages: ${conversation.messageCount}`);
  console.log(`  Participants: ${conversation.participants.join(", ")}`);
  if (dateRange) {
    console.log(`  Started: ${dateRange.started.split("T")[0]}`);
    console.log(`  Last message: ${dateRange.lastMessage.split("T")[0]}`);
  }

  console.log("\n--- Conversation ---");
  for (const msg of conversation.messages) {
    const fromName = extractName(msg.from);
    console.log(`\n[${msg.date}] ${fromName}:`);
    console.log(msg.body.slice(0, 500) + (msg.body.length > 500 ? "..." : ""));
  }

  console.log("\n--- Full Data ---");
  console.log(JSON.stringify(conversation, null, 2));

} catch (error) {
  console.error("Error fetching thread:", error.message);
  throw error;
}

function extractEmails(header) {
  if (!header) return [];
  const emails = [];
  const regex = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
  let match;
  while ((match = regex.exec(header)) !== null) {
    emails.push(match[1].toLowerCase());
  }
  return emails;
}

function extractName(fromHeader) {
  if (!fromHeader) return "Unknown";
  const match = fromHeader.match(/^([^<]+)</);
  if (match) return match[1].trim().replace(/"/g, "");
  return fromHeader.split("@")[0];
}

function extractBody(payload) {
  if (!payload) return "";

  // Direct body
  if (payload.body?.data) {
    return decodeBase64(payload.body.data);
  }

  // Multipart - prefer text/plain, fall back to text/html
  if (payload.parts) {
    // First pass: look for text/plain
    for (const part of payload.parts) {
      if (part.mimeType === "text/plain" && part.body?.data) {
        return decodeBase64(part.body.data);
      }
    }
    // Second pass: look for text/html and strip tags
    for (const part of payload.parts) {
      if (part.mimeType === "text/html" && part.body?.data) {
        return stripHtml(decodeBase64(part.body.data));
      }
    }
    // Recurse into nested parts
    for (const part of payload.parts) {
      if (part.parts) {
        const nested = extractBody(part);
        if (nested) return nested;
      }
    }
  }

  return "";
}

function decodeBase64(data) {
  try {
    return Buffer.from(data, "base64url").toString("utf-8");
  } catch {
    try {
      return Buffer.from(data, "base64").toString("utf-8");
    } catch {
      return "";
    }
  }
}

function stripHtml(html) {
  return html
    .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, ">")
    .replace(/&quot;/g, '"')
    .replace(/\s+/g, " ")
    .trim();
}

function stripQuotedContent(body) {
  const lines = body.split("\n");
  const cleaned = [];
  let inQuote = false;

  for (const line of lines) {
    // Detect quote markers
    if (
      line.match(/^>/) ||
      line.match(/^On .+ wrote:$/i) ||
      line.match(/^-{3,}\s*Original Message\s*-{3,}/i) ||
      line.match(/^_{3,}/) ||
      line.match(/^From:\s+.+$/i) && inQuote
    ) {
      inQuote = true;
      continue;
    }

    // Reset quote detection on blank lines after non-quote content
    if (line.trim() === "" && cleaned.length > 0 && !inQuote) {
      cleaned.push(line);
      continue;
    }

    if (!inQuote) {
      cleaned.push(line);
    }
  }

  return cleaned.join("\n").trim();
}
                  const [threadId, includeFullBody = "true"] = process.argv.slice(2);
const includeBodies = includeFullBody !== "false";

if (!threadId) {
  console.error("Error: threadId is required");
  process.exit(1);
}

console.log(`Fetching thread: ${threadId}`);

try {
  const format = includeBodies ? "full" : "metadata";
  const url = `https://gmail.googleapis.com/gmail/v1/users/me/threads/${threadId}?format=${format}`;

  const res = await fetch(url, {
    headers: { Authorization: "Bearer PLACEHOLDER_TOKEN" },
  });

  if (!res.ok) {
    const errorText = await res.text();
    console.error(`Gmail API error: ${res.status}`);
    console.error(errorText);
    throw new Error(`Failed to fetch thread: ${res.status}`);
  }

  const thread = await res.json();

  if (!thread.messages || thread.messages.length === 0) {
    console.log("Thread has no messages");
    console.log(JSON.stringify({ threadId, messages: [] }));
    process.exit(0);
  }

  // Extract participants
  const participants = new Set();
  const messages = [];

  for (const msg of thread.messages) {
    const getHeader = (name) => {
      const header = msg.payload?.headers?.find(
        (h) => h.name.toLowerCase() === name.toLowerCase()
      );
      return header ? header.value : "";
    };

    const from = getHeader("From");
    const to = getHeader("To");
    const cc = getHeader("Cc");

    // Collect participants
    extractEmails(from).forEach((e) => participants.add(e));
    extractEmails(to).forEach((e) => participants.add(e));
    extractEmails(cc).forEach((e) => participants.add(e));

    let body = msg.snippet || "";
    if (includeBodies) {
      body = extractBody(msg.payload);
      body = stripQuotedContent(body);
    }

    messages.push({
      id: msg.id,
      from: from,
      to: to,
      cc: cc || undefined,
      date: getHeader("Date"),
      timestamp: parseInt(msg.internalDate) || null,
      subject: getHeader("Subject"),
      body: body.trim(),
      snippet: msg.snippet,
      labelIds: msg.labelIds || [],
    });
  }

  // Sort by timestamp (chronological)
  messages.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));

  // Compute date range
  const dates = messages.filter((m) => m.timestamp).map((m) => new Date(m.timestamp));
  const dateRange = dates.length > 0
    ? {
        started: dates[0].toISOString(),
        lastMessage: dates[dates.length - 1].toISOString(),
      }
    : null;

  const conversation = {
    threadId: thread.id,
    subject: messages[0]?.subject || "No Subject",
    messageCount: messages.length,
    participants: Array.from(participants),
    dateRange,
    messages: messages.map((m) => ({
      id: m.id,
      from: m.from,
      to: m.to,
      cc: m.cc,
      date: m.date,
      body: m.body,
    })),
  };

  console.log(`\n✓ Thread: ${conversation.subject}`);
  console.log(`  Messages: ${conversation.messageCount}`);
  console.log(`  Participants: ${conversation.participants.join(", ")}`);
  if (dateRange) {
    console.log(`  Started: ${dateRange.started.split("T")[0]}`);
    console.log(`  Last message: ${dateRange.lastMessage.split("T")[0]}`);
  }

  console.log("\n--- Conversation ---");
  for (const msg of conversation.messages) {
    const fromName = extractName(msg.from);
    console.log(`\n[${msg.date}] ${fromName}:`);
    console.log(msg.body.slice(0, 500) + (msg.body.length > 500 ? "..." : ""));
  }

  console.log("\n--- Full Data ---");
  console.log(JSON.stringify(conversation, null, 2));

} catch (error) {
  console.error("Error fetching thread:", error.message);
  throw error;
}

function extractEmails(header) {
  if (!header) return [];
  const emails = [];
  const regex = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
  let match;
  while ((match = regex.exec(header)) !== null) {
    emails.push(match[1].toLowerCase());
  }
  return emails;
}

function extractName(fromHeader) {
  if (!fromHeader) return "Unknown";
  const match = fromHeader.match(/^([^<]+)]*>[\s\S]*?<\/style>/gi, "")
    .replace(/]*>[\s\S]*?<\/script>/gi, "")
    .replace(/<[^>]+>/g, " ")
    .replace(/ /g, " ")
    .replace(/&/g, "&")
    .replace(/</g, "<")
    .replace(/>/g, ">")
    .replace(/"/g, '"')
    .replace(/\s+/g, " ")
    .trim();
}

function stripQuotedContent(body) {
  const lines = body.split("\n");
  const cleaned = [];
  let inQuote = false;

  for (const line of lines) {
    // Detect quote markers
    if (
      line.match(/^>/) ||
      line.match(/^On .+ wrote:$/i) ||
      line.match(/^-{3,}\s*Original Message\s*-{3,}/i) ||
      line.match(/^_{3,}/) ||
      line.match(/^From:\s+.+$/i) && inQuote
    ) {
      inQuote = true;
      continue;
    }

    // Reset quote detection on blank lines after non-quote content
    if (line.trim() === "" && cleaned.length > 0 && !inQuote) {
      cleaned.push(line);
      continue;
    }

    if (!inQuote) {
      cleaned.push(line);
    }
  }

  return cleaned.join("\n").trim();
}