code icon Code

Compute Email Roast Stats

Analyze Gmail data for roastable patterns: follow-ups ignored, late-night emails, meeting spam, desperate behavior

Source Code

import fs from "fs";

const [inputPath = "session/emails.json", outputPath = "session/roast_metrics.json"] =
  process.argv.slice(2);

console.log(`Analyzing emails for roastable patterns: ${inputPath}`);

try {
  if (!fs.existsSync(inputPath)) {
    console.error(`File not found: ${inputPath}`);
    process.exit(1);
  }

  const raw = fs.readFileSync(inputPath, "utf-8");
  const data = JSON.parse(raw);
  const messages = data.messages || [];

  if (messages.length === 0) {
    console.log("No messages to roast. Suspicious...");
    const emptyStats = {
      totalMessages: 0,
      verdict: "Your inbox is suspiciously clean. What are you hiding?",
    };
    fs.writeFileSync(outputPath, JSON.stringify(emptyStats, null, 2));
    console.log(JSON.stringify({ success: true, outputPath, stats: emptyStats }));
    process.exit(0);
  }

  console.log(`Analyzing ${messages.length} messages for sins...`);

  // Separate sent vs received
  const sent = messages.filter((m) =>
    m.labelIds?.includes("SENT") || m.from?.toLowerCase().includes("me")
  );
  const received = messages.filter((m) => !sent.includes(m));

  // ===== FOLLOW-UP PATTERNS (THE CRINGE) =====
  const followUpPatterns = [
    /just following up/i,
    /circling back/i,
    /checking in/i,
    /wanted to follow up/i,
    /bumping this/i,
    /any update/i,
    /did you get a chance/i,
    /touching base/i,
    /looping back/i,
    /gentle reminder/i,
    /friendly reminder/i,
    /per my last email/i,
  ];

  const followUpEmails = sent.filter((m) =>
    followUpPatterns.some((p) => p.test(m.subject) || p.test(m.snippet))
  );

  // Check which follow-ups got responses (by thread)
  const followUpThreads = new Set(followUpEmails.map((m) => m.threadId));
  const threadMessages = {};
  for (const m of messages) {
    if (!threadMessages[m.threadId]) threadMessages[m.threadId] = [];
    threadMessages[m.threadId].push(m);
  }

  let followUpsIgnored = 0;
  for (const threadId of followUpThreads) {
    const thread = threadMessages[threadId] || [];
    const followUpInThread = followUpEmails.find((f) => f.threadId === threadId);
    if (!followUpInThread) continue;
    const followUpDate = new Date(followUpInThread.date);
    const repliesAfter = thread.filter((m) => {
      const mDate = new Date(m.date);
      return mDate > followUpDate && !sent.includes(m);
    });
    if (repliesAfter.length === 0) followUpsIgnored++;
  }

  // ===== LATE NIGHT / WEEKEND EMAILS =====
  const lateNightEmails = [];
  const weekendEmails = [];
  const veryEarlyEmails = [];

  for (const m of sent) {
    if (!m.date) continue;
    const d = new Date(m.date);
    if (isNaN(d.getTime())) continue;

    const hour = d.getHours();
    const day = d.getDay();

    if (hour >= 22 || hour < 5) lateNightEmails.push(m);
    if (hour >= 5 && hour < 7) veryEarlyEmails.push(m);
    if (day === 0 || day === 6) weekendEmails.push(m);
  }

  // ===== MEETING REQUEST SPAM =====
  const meetingPatterns = [
    /meeting/i,
    /calendar invite/i,
    /sync up/i,
    /quick call/i,
    /hop on a call/i,
    /15 minutes/i,
    /30 minutes/i,
    /schedule/i,
    /available for/i,
  ];

  const meetingRequests = sent.filter((m) =>
    meetingPatterns.some((p) => p.test(m.subject) || p.test(m.snippet))
  );

  // ===== RESPONSE TIME STATS =====
  const responseTimes = [];
  for (const threadId of Object.keys(threadMessages)) {
    const thread = threadMessages[threadId].sort(
      (a, b) => new Date(a.date) - new Date(b.date)
    );
    for (let i = 1; i < thread.length; i++) {
      const prev = thread[i - 1];
      const curr = thread[i];
      const prevIsSent = sent.includes(prev);
      const currIsSent = sent.includes(curr);
      // Measure time to respond to received emails
      if (!prevIsSent && currIsSent) {
        const prevDate = new Date(prev.date);
        const currDate = new Date(curr.date);
        if (!isNaN(prevDate.getTime()) && !isNaN(currDate.getTime())) {
          const diffMinutes = (currDate - prevDate) / (1000 * 60);
          if (diffMinutes > 0 && diffMinutes < 43200) {
            // Max 30 days
            responseTimes.push(diffMinutes);
          }
        }
      }
    }
  }

  const avgResponseMinutes =
    responseTimes.length > 0
      ? Math.round(responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length)
      : null;
  const fastestResponse = responseTimes.length > 0 ? Math.round(Math.min(...responseTimes)) : null;
  const slowestResponse = responseTimes.length > 0 ? Math.round(Math.max(...responseTimes)) : null;

  // ===== DESPERATE SENDER (single person obsession) =====
  const recipientCounts = {};
  for (const m of sent) {
    const to = extractEmail(m.to);
    if (to && to !== "unknown") {
      recipientCounts[to] = (recipientCounts[to] || 0) + 1;
    }
  }
  const topRecipients = Object.entries(recipientCounts)
    .sort((a, b) => b[1] - a[1])
    .slice(0, 5)
    .map(([email, count]) => ({
      email,
      count,
      percentage: Math.round((count / sent.length) * 100),
    }));

  // ===== WHO IGNORES YOU MOST =====
  const senderCounts = {};
  for (const m of received) {
    const from = extractEmail(m.from);
    if (from && from !== "unknown") {
      senderCounts[from] = (senderCounts[from] || 0) + 1;
    }
  }
  const topSenders = Object.entries(senderCounts)
    .sort((a, b) => b[1] - a[1])
    .slice(0, 5)
    .map(([email, count]) => ({ email, count }));

  // ===== EMAIL LENGTH (VERBOSITY) =====
  const snippetLengths = sent.map((m) => (m.snippet || "").length);
  const avgSnippetLength =
    snippetLengths.length > 0
      ? Math.round(snippetLengths.reduce((a, b) => a + b, 0) / snippetLengths.length)
      : 0;

  // ===== THREAD ABANDONMENT =====
  let abandonedThreads = 0;
  let totalThreads = 0;
  for (const threadId of Object.keys(threadMessages)) {
    const thread = threadMessages[threadId];
    if (thread.length < 2) continue;
    totalThreads++;
    const lastMessage = thread.sort((a, b) => new Date(b.date) - new Date(a.date))[0];
    // If the last message in thread is FROM someone else (not you), you abandoned it
    if (received.includes(lastMessage)) {
      abandonedThreads++;
    }
  }
  const abandonmentRate =
    totalThreads > 0 ? Math.round((abandonedThreads / totalThreads) * 100) : 0;

  // ===== HOUR OF DAY DISTRIBUTION =====
  const hourDistribution = {};
  for (let i = 0; i < 24; i++) hourDistribution[i] = 0;
  for (const m of sent) {
    if (!m.date) continue;
    const d = new Date(m.date);
    if (!isNaN(d.getTime())) {
      hourDistribution[d.getHours()]++;
    }
  }
  const peakHour = Object.entries(hourDistribution).reduce((a, b) =>
    b[1] > a[1] ? b : a
  );

  // ===== COMPILE ROAST STATS =====
  const stats = {
    analyzed_at: new Date().toISOString(),
    total_emails: messages.length,
    sent_count: sent.length,
    received_count: received.length,

    // The Cringe Stats
    follow_ups: {
      total_sent: followUpEmails.length,
      ignored: followUpsIgnored,
      ignored_rate: followUpEmails.length > 0
        ? Math.round((followUpsIgnored / followUpEmails.length) * 100)
        : 0,
      sample_subjects: followUpEmails.slice(0, 3).map((m) => m.subject),
    },

    // Boundary Issues
    late_night: {
      count: lateNightEmails.length,
      percentage: sent.length > 0
        ? Math.round((lateNightEmails.length / sent.length) * 100)
        : 0,
    },
    weekend: {
      count: weekendEmails.length,
      percentage: sent.length > 0
        ? Math.round((weekendEmails.length / sent.length) * 100)
        : 0,
    },
    early_bird: {
      count: veryEarlyEmails.length,
      percentage: sent.length > 0
        ? Math.round((veryEarlyEmails.length / sent.length) * 100)
        : 0,
    },

    // Meeting Addict
    meeting_requests: {
      count: meetingRequests.length,
      percentage: sent.length > 0
        ? Math.round((meetingRequests.length / sent.length) * 100)
        : 0,
    },

    // Response Behavior
    response_time: {
      average_minutes: avgResponseMinutes,
      fastest_minutes: fastestResponse,
      slowest_minutes: slowestResponse,
      average_human: formatDuration(avgResponseMinutes),
      fastest_human: formatDuration(fastestResponse),
      slowest_human: formatDuration(slowestResponse),
    },

    // Relationship Patterns
    top_recipients: topRecipients,
    top_senders: topSenders,
    recipient_obsession: topRecipients[0] || null,

    // Conversation Style
    verbosity: {
      avg_snippet_length: avgSnippetLength,
      assessment:
        avgSnippetLength > 150
          ? "novelist"
          : avgSnippetLength > 80
            ? "normal"
            : avgSnippetLength > 40
              ? "terse"
              : "one-word-reply-king",
    },

    // Thread Behavior
    thread_abandonment: {
      rate: abandonmentRate,
      abandoned: abandonedThreads,
      total: totalThreads,
    },

    // Time Patterns
    peak_hour: {
      hour: parseInt(peakHour[0]),
      count: peakHour[1],
      description: formatHour(parseInt(peakHour[0])),
    },
    hour_distribution: hourDistribution,
  };

  // Write output
  const dir = outputPath.includes("/") ? outputPath.split("/").slice(0, -1).join("/") : null;
  if (dir) fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(outputPath, JSON.stringify(stats, null, 2));

  // Log summary
  console.log(`\n✓ Roast analysis complete`);
  console.log(`  Total emails: ${stats.total_emails}`);
  console.log(`  Follow-ups sent: ${stats.follow_ups.total_sent}`);
  console.log(`  Follow-ups IGNORED: ${stats.follow_ups.ignored} (${stats.follow_ups.ignored_rate}%)`);
  console.log(`  Late night emails: ${stats.late_night.count}`);
  console.log(`  Weekend emails: ${stats.weekend.count}`);
  console.log(`  Meeting requests: ${stats.meeting_requests.count}`);
  console.log(`  Avg response time: ${stats.response_time.average_human || "N/A"}`);
  console.log(`  Thread abandonment: ${stats.thread_abandonment.rate}%`);
  console.log(`  Peak sending hour: ${stats.peak_hour.description}`);
  console.log(`  Written to: ${outputPath}`);

  console.log(JSON.stringify({ success: true, outputPath, stats }));
} catch (error) {
  console.error("Error analyzing emails:", error.message);
  throw error;
}

function extractEmail(header) {
  if (!header) return "unknown";
  const match = header.match(/<([^>]+)>/);
  return match ? match[1].toLowerCase() : header.toLowerCase().trim();
}

function formatDuration(minutes) {
  if (minutes === null) return null;
  if (minutes < 60) return `${minutes} minutes`;
  if (minutes < 1440) return `${Math.round(minutes / 60)} hours`;
  return `${Math.round(minutes / 1440)} days`;
}

function formatHour(hour) {
  if (hour === 0) return "Midnight (12 AM)";
  if (hour === 12) return "Noon (12 PM)";
  if (hour < 12) return `${hour} AM`;
  return `${hour - 12} PM`;
}