code icon Code

Scan Emails for Bingo Phrases

Fetch sent emails and count occurrences of 25 corporate email cliches

Source Code

import fs from "fs";

// Note: This code follows Gmail API patterns from skill:gmail.inbox.search
// but requires format=full for body text scanning (existing code:gmail.inbox.fetch
// only supports metadata format). The phrase counting logic is bingo-specific.

// The 25 bingo phrases with their search patterns (case-insensitive)
const BINGO_PHRASES = [
  // B Column - Apologetic
  { id: "B1", phrase: "Sorry for the delay", patterns: ["sorry for the delay", "apologies for the delay", "sorry for my late"] },
  { id: "B2", phrase: "Apologies for the confusion", patterns: ["apologies for the confusion", "sorry for the confusion", "apologize for any confusion"] },
  { id: "B3", phrase: "Sorry to bother you", patterns: ["sorry to bother", "hate to bother", "apologies for bothering"] },
  { id: "B4", phrase: "I apologize for any inconvenience", patterns: ["apologize for any inconvenience", "sorry for any inconvenience", "apologies for the inconvenience"] },
  { id: "B5", phrase: "Sorry for the late reply", patterns: ["sorry for the late reply", "apologies for the late", "sorry for my delayed"] },

  // I Column - Passive-Aggressive
  { id: "I1", phrase: "Per my last email", patterns: ["per my last email", "as per my last", "as i mentioned", "as mentioned in my previous"] },
  { id: "I2", phrase: "As previously mentioned", patterns: ["as previously mentioned", "as i previously", "mentioned previously", "as noted earlier"] },
  { id: "I3", phrase: "Just to clarify", patterns: ["just to clarify", "to clarify", "for clarification", "let me clarify"] },
  { id: "I4", phrase: "Going forward", patterns: ["going forward", "moving forward", "from now on"] },
  { id: "I5", phrase: "As per our conversation", patterns: ["as per our conversation", "per our conversation", "as we discussed", "as discussed"] },

  // N Column - Corporate Jargon
  { id: "N1", phrase: "Circle back", patterns: ["circle back", "circling back", "circle around"] },
  { id: "N2", phrase: "Touch base", patterns: ["touch base", "touching base", "quick touch base"] },
  { id: "N3", phrase: "FREE SPACE", patterns: [] }, // Everyone gets this one
  { id: "N4", phrase: "Synergies", patterns: ["synergy", "synergies", "synergize", "synergistic"] },
  { id: "N5", phrase: "Low-hanging fruit", patterns: ["low-hanging fruit", "low hanging fruit", "quick wins"] },

  // G Column - Filler Phrases
  { id: "G1", phrase: "Hope this email finds you well", patterns: ["hope this email finds you", "hope this finds you well", "hope you're doing well", "hope you are well"] },
  { id: "G2", phrase: "Let me know if you have any questions", patterns: ["let me know if you have any questions", "feel free to reach out", "don't hesitate to ask"] },
  { id: "G3", phrase: "Hope that makes sense", patterns: ["hope that makes sense", "hope this makes sense", "let me know if that makes sense", "does that make sense"] },
  { id: "G4", phrase: "Please advise", patterns: ["please advise", "kindly advise", "please let me know"] },
  { id: "G5", phrase: "Thanks in advance", patterns: ["thanks in advance", "thank you in advance", "appreciate your help in advance"] },

  // O Column - Urgency Theater
  { id: "O1", phrase: "ASAP", patterns: ["asap", "as soon as possible", "at your earliest"] },
  { id: "O2", phrase: "Just following up", patterns: ["just following up", "following up on", "quick follow up", "wanted to follow up"] },
  { id: "O3", phrase: "Friendly reminder", patterns: ["friendly reminder", "gentle reminder", "quick reminder", "kind reminder"] },
  { id: "O4", phrase: "At your earliest convenience", patterns: ["at your earliest convenience", "when you get a chance", "whenever you have time"] },
  { id: "O5", phrase: "Wanted to flag", patterns: ["wanted to flag", "flagging this", "just flagging", "to flag"] },
];

const CONCURRENCY = 10;

const [outputPath, maxEmailsArg = "200"] = process.argv.slice(2);
const maxEmails = parseInt(maxEmailsArg, 10);

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

/**
 * Fetch message IDs for sent emails
 */
async function fetchMessageIds(maxResults) {
  const ids = [];
  let pageToken = null;

  while (ids.length < maxResults) {
    const remaining = maxResults - ids.length;
    const pageSize = Math.min(remaining, 100);

    const url = new URL("https://gmail.googleapis.com/gmail/v1/users/me/messages");
    url.searchParams.set("maxResults", pageSize.toString());
    url.searchParams.set("q", "in:sent newer_than:90d");
    if (pageToken) url.searchParams.set("pageToken", pageToken);

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

    if (!res.ok) {
      const text = await res.text();
      throw new Error(`Gmail API failed: ${res.status} - ${text}`);
    }

    const data = await res.json();
    if (!data.messages || data.messages.length === 0) break;

    ids.push(...data.messages.map((m) => m.id).slice(0, remaining));
    pageToken = data.nextPageToken;
    if (!pageToken) break;
  }

  return ids;
}

/**
 * Fetch full message content
 */
async function fetchMessages(messageIds) {
  const results = [];

  for (let i = 0; i < messageIds.length; i += CONCURRENCY) {
    const batch = messageIds.slice(i, i + CONCURRENCY);
    const fetched = await Promise.all(
      batch.map(async (id) => {
        const url = `https://gmail.googleapis.com/gmail/v1/users/me/messages/${id}?format=full`;
        const res = await fetch(url, {
          headers: { Authorization: "Bearer PLACEHOLDER_TOKEN" },
        });
        if (!res.ok) return null;
        try {
          return await res.json();
        } catch {
          return null;
        }
      })
    );
    results.push(...fetched.filter(Boolean));
    console.log(`  Fetched ${Math.min(i + CONCURRENCY, messageIds.length)}/${messageIds.length}...`);
  }

  return results;
}

/**
 * Extract plain text body from Gmail message
 */
function extractBodyText(payload) {
  if (!payload) return "";

  // Direct body
  if (payload.body?.data) {
    return Buffer.from(payload.body.data, "base64").toString("utf-8");
  }

  // Multipart - look for text/plain
  if (payload.parts) {
    for (const part of payload.parts) {
      if (part.mimeType === "text/plain" && part.body?.data) {
        return Buffer.from(part.body.data, "base64").toString("utf-8");
      }
      // Nested parts
      if (part.parts) {
        const nested = extractBodyText(part);
        if (nested) return nested;
      }
    }
    // Fallback to text/html if no plain text
    for (const part of payload.parts) {
      if (part.mimeType === "text/html" && part.body?.data) {
        const html = Buffer.from(part.body.data, "base64").toString("utf-8");
        return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ");
      }
    }
  }

  return "";
}

/**
 * Count phrase occurrences in text
 */
function countPhrases(text) {
  const lowerText = text.toLowerCase();
  const counts = {};

  for (const { id, phrase, patterns } of BINGO_PHRASES) {
    if (patterns.length === 0) {
      // FREE SPACE
      counts[id] = { phrase, count: 1 };
      continue;
    }

    let count = 0;
    for (const pattern of patterns) {
      const regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
      const matches = lowerText.match(regex);
      if (matches) count += matches.length;
    }
    counts[id] = { phrase, count };
  }

  return counts;
}

async function main() {
  console.log("๐ŸŽฏ Inbox Bingo - Scanning for corporate email crimes...\n");

  // Fetch sent email IDs
  console.log("Phase 1: Finding sent emails...");
  const messageIds = await fetchMessageIds(maxEmails);
  console.log(`  Found ${messageIds.length} sent emails from the last 90 days\n`);

  if (messageIds.length === 0) {
    console.error("No sent emails found in the last 90 days.");
    const emptyResult = {
      success: false,
      error: "no_emails_found",
      emailCount: 0,
      phrases: {},
    };
    fs.writeFileSync(outputPath, JSON.stringify(emptyResult, null, 2));
    process.exit(1);
  }

  // Fetch full messages
  console.log("Phase 2: Reading email content...");
  const messages = await fetchMessages(messageIds);
  console.log(`  Retrieved ${messages.length} emails\n`);

  // Extract and combine all text
  console.log("Phase 3: Counting corporate crimes...");
  let allText = "";
  for (const msg of messages) {
    const body = extractBodyText(msg.payload);
    allText += " " + body;
  }

  // Count phrases
  const phraseCounts = countPhrases(allText);

  // Calculate stats
  let totalDisasterScore = 0;
  let filledSquares = 0;
  const topPhrases = [];

  for (const [id, { phrase, count }] of Object.entries(phraseCounts)) {
    if (count > 0) {
      filledSquares++;
      totalDisasterScore += count;
      if (id !== "N3") { // Exclude FREE SPACE from top phrases
        topPhrases.push({ id, phrase, count });
      }
    }
  }

  // Sort by count descending
  topPhrases.sort((a, b) => b.count - a.count);

  // Calculate bingo lines
  const grid = [
    ["B1", "I1", "N1", "G1", "O1"],
    ["B2", "I2", "N2", "G2", "O2"],
    ["B3", "I3", "N3", "G3", "O3"],
    ["B4", "I4", "N4", "G4", "O4"],
    ["B5", "I5", "N5", "G5", "O5"],
  ];

  let bingoCount = 0;
  const bingos = [];

  // Check rows
  for (let row = 0; row < 5; row++) {
    if (grid[row].every((id) => phraseCounts[id]?.count > 0)) {
      bingoCount++;
      bingos.push(`Row ${row + 1}`);
    }
  }

  // Check columns
  const columns = ["B", "I", "N", "G", "O"];
  for (let col = 0; col < 5; col++) {
    const colIds = [1, 2, 3, 4, 5].map((n) => `${columns[col]}${n}`);
    if (colIds.every((id) => phraseCounts[id]?.count > 0)) {
      bingoCount++;
      bingos.push(`${columns[col]} Column`);
    }
  }

  // Check diagonals
  const diag1 = ["B1", "I2", "N3", "G4", "O5"];
  const diag2 = ["B5", "I4", "N3", "G2", "O1"];
  if (diag1.every((id) => phraseCounts[id]?.count > 0)) {
    bingoCount++;
    bingos.push("Diagonal โ†˜");
  }
  if (diag2.every((id) => phraseCounts[id]?.count > 0)) {
    bingoCount++;
    bingos.push("Diagonal โ†—");
  }

  // Build result
  const result = {
    success: true,
    emailCount: messages.length,
    filledSquares,
    totalDisasterScore,
    bingoCount,
    bingos,
    topPhrases: topPhrases.slice(0, 10),
    phrases: phraseCounts,
    scannedAt: new Date().toISOString(),
  };

  // Write results
  const dir = outputPath.substring(0, outputPath.lastIndexOf("/"));
  if (dir) fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(outputPath, JSON.stringify(result, null, 2));

  // Log summary
  console.log("\n๐ŸŽฏ BINGO RESULTS:");
  console.log(`  Emails scanned: ${messages.length}`);
  console.log(`  Squares filled: ${filledSquares}/25`);
  console.log(`  Total disaster score: ${totalDisasterScore}`);
  console.log(`  Bingos: ${bingoCount} (${bingos.join(", ") || "none"})`);
  console.log("\n  Top corporate crimes:");
  for (const { phrase, count } of topPhrases.slice(0, 5)) {
    console.log(`    โ€ข "${phrase}": ${count}x`);
  }
  console.log(`\nโœ“ Results saved to: ${outputPath}`);

  console.log(JSON.stringify({ success: true, outputPath, bingoCount, filledSquares, totalDisasterScore }, null, 2));
}

main().catch((err) => {
  console.error("Error:", err.message);
  process.exit(1);
});