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