// Enhanced Thread ID Extractor for Gmail // This provides bulletproof thread ID extraction using multiple fallback methods class GmailThreadExtractor { constructor() { this.cache = new Map(); // Cache thread IDs to avoid repeated extraction this.observers = new Set(); // Track active observers this.lastKnownThreadId = null; this.extractionMethods = [ this.extractFromURL, this.extractFromDOM, this.extractFromGmailAPI, this.extractFromMessageHeaders, this.extractFromBrowserHistory, this.extractFromGmailInternals, this.extractFromFallbackMethods ]; // Add batch-specific selectors this.batchSelectors = { selectedCheckboxes: 'div[role="checkbox"][aria-checked="true"]', threadRow: 'tr[data-legacy-thread-id], tr[data-thread-id]', subjectElement: 'span.bog', senderElement: 'span.yX.xY span.zF, span.yX.xY span.yP' }; } // Main extraction method with comprehensive fallbacks async extractThreadId(context = 'current', options = {}) { const cacheKey = `${context}_${window.location.href}`; // Check cache first if (this.cache.has(cacheKey) && !options.forceRefresh) { console.log('Thread ID found in cache:', this.cache.get(cacheKey)); return this.cache.get(cacheKey); } console.log(`=== EXTRACTING THREAD ID (${context}) ===`); // Handle batch selection context differently if (context === 'selected') { return this.extractBatchThreadIds(options); } // Try each extraction method in order for (const method of this.extractionMethods) { try { const threadId = await method.call(this, context, options); if (threadId && this.validateThreadId(threadId)) { console.log(`Thread ID found via ${method.name}:`, threadId); this.cache.set(cacheKey, threadId); this.lastKnownThreadId = threadId; return threadId; } } catch (error) { console.warn(`Method ${method.name} failed:`, error); } } // If all methods fail, try emergency fallbacks const emergencyId = await this.emergencyExtraction(context); if (emergencyId) { console.log('Thread ID found via emergency method:', emergencyId); return emergencyId; } console.error('All thread ID extraction methods failed'); return null; } // Specialized method for batch thread extraction extractBatchThreadIds(options = {}) { console.log('=== EXTRACTING BATCH THREAD IDS ==='); const selectedCheckboxes = document.querySelectorAll(this.batchSelectors.selectedCheckboxes); const threadIds = []; const threadInfo = []; selectedCheckboxes.forEach((checkbox, index) => { const row = checkbox.closest('tr'); if (!row) return; // Try multiple extraction methods for each row let threadId = null; let extractionMethod = 'unknown'; // Method 1: Direct attributes threadId = row.getAttribute('data-legacy-thread-id') || row.getAttribute('data-thread-id'); if (threadId) { extractionMethod = 'row-attribute'; } // Method 2: Child elements if (!threadId) { const threadElement = row.querySelector('[data-legacy-thread-id], [data-thread-id]'); if (threadElement) { threadId = threadElement.getAttribute('data-legacy-thread-id') || threadElement.getAttribute('data-thread-id'); extractionMethod = 'child-element'; } } // Method 3: Row ID parsing if (!threadId && row.id) { const match = row.id.match(/([a-f0-9]{16})/i); if (match && this.validateThreadId(match[1])) { threadId = match[1]; extractionMethod = 'row-id'; } } // Method 4: Click and extract (if enabled) if (!threadId && options.allowClickExtraction) { threadId = this.extractByClicking(row); extractionMethod = 'click-extraction'; } if (threadId && this.validateThreadId(threadId)) { threadIds.push(threadId); // Collect additional info for better logging const subject = row.querySelector(this.batchSelectors.subjectElement)?.textContent?.trim(); const sender = row.querySelector(this.batchSelectors.senderElement)?.textContent?.trim(); threadInfo.push({ threadId, subject, sender, extractionMethod, index }); } }); // Log detailed extraction results console.log(`Found ${threadIds.length} thread IDs from ${selectedCheckboxes.length} selected items`); threadInfo.forEach(info => { console.log(`Thread ${info.index + 1}: ${info.threadId} (${info.extractionMethod}) - "${info.subject}" from ${info.sender}`); }); // Store thread info for potential fallback matching if (options.returnFullInfo) { return threadInfo; } return [...new Set(threadIds)]; // Remove duplicates } // Method 1: Extract from URL (most reliable) extractFromURL(context) { const url = window.location.href; const hash = window.location.hash; // Pattern 1: #inbox/thread_id let match = hash.match(/[#/]([a-f0-9]{16})$/i); if (match) return match[1]; // Pattern 2: #search/query/thread_id match = hash.match(/[#/]([a-f0-9]{16})(?:[/?]|$)/i); if (match) return match[1]; // Pattern 3: URL parameters const urlParams = new URLSearchParams(window.location.search); const threadParam = urlParams.get('th') || urlParams.get('thread'); if (threadParam && this.validateThreadId(threadParam)) return threadParam; // Pattern 4: Path-based thread ID match = url.match(/\/mail\/u\/\d+\/#[^/]*\/([a-f0-9]{16})/i); if (match) return match[1]; // Pattern 5: Label or search views with thread ID match = hash.match(/label\/[^/]+\/([a-f0-9]{16})/i); if (match) return match[1]; return null; } // Method 2: Extract from DOM elements extractFromDOM(context) { const selectors = [ // Primary selectors '[data-legacy-thread-id]', '[data-thread-perm-id]', '[data-thread-id]', // Conversation view selectors 'h2[data-legacy-thread-id]', 'h2[data-thread-perm-id]', '.nH.if [data-legacy-thread-id]', // Message container selectors 'div[role="listitem"][data-legacy-thread-id]', '.h7[data-legacy-thread-id]', // Gmail-specific selectors '.adn[data-legacy-thread-id]', '.zA[data-legacy-thread-id]', // Additional Gmail UI selectors '.ae4[data-legacy-thread-id]', // Thread container '.Cp[data-legacy-thread-id]', // Message list '.gs[data-legacy-thread-id]', // Conversation wrapper // Fallback selectors '[jsaction*="thread"]', '[data-tooltip*="thread"]' ]; // Prioritize visible elements for (const selector of selectors) { const elements = document.querySelectorAll(selector); for (const element of elements) { // Check if element is visible if (element.offsetParent === null) continue; const threadId = element.getAttribute('data-legacy-thread-id') || element.getAttribute('data-thread-perm-id') || element.getAttribute('data-thread-id'); if (threadId && this.validateThreadId(threadId)) { return threadId; } } } // Check for thread ID in element IDs or classes const allElements = document.querySelectorAll('*[id*="thread"], *[class*="thread"]'); for (const element of allElements) { const id = element.id || element.className; const match = id.match(/([a-f0-9]{16})/i); if (match && this.validateThreadId(match[1])) { return match[1]; } } return null; } // Method 3: Extract using Gmail API async extractFromGmailAPI(context, options) { if (!options.token) { // Try to get token from background script try { const response = await new Promise((resolve) => { chrome.runtime.sendMessage({action: 'getOAuthToken'}, resolve); }); if (response && response.token) { options.token = response.token; } else { return null; } } catch (error) { console.warn('Could not get OAuth token for API extraction'); return null; } } // Try multiple methods to get a message ID const messageId = this.extractMessageId() || await this.extractMessageIdFromAPI(options.token); if (!messageId) return null; try { // Get message details from Gmail API const response = await fetch( `https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}`, { headers: { 'Authorization': `Bearer ${options.token}` } } ); if (response.ok) { const messageData = await response.json(); return messageData.threadId; } } catch (error) { console.warn('Gmail API extraction failed:', error); } return null; } // Extract message ID from Gmail API (when DOM fails) async extractMessageIdFromAPI(token) { try { // Get the most recent message const response = await fetch( 'https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=1', { headers: { 'Authorization': `Bearer ${token}` } } ); if (response.ok) { const data = await response.json(); if (data.messages && data.messages.length > 0) { return data.messages[0].id; } } } catch (error) { console.warn('Failed to get message ID from API:', error); } return null; } // Method 4: Extract from message headers extractFromMessageHeaders() { // Look for message elements with headers const messageElements = document.querySelectorAll('div[role="listitem"]'); for (const element of messageElements) { // Check for thread ID in data attributes const threadId = element.getAttribute('data-legacy-thread-id') || element.getAttribute('data-thread-id'); if (threadId && this.validateThreadId(threadId)) { return threadId; } // Check parent containers const parent = element.closest('[data-legacy-thread-id], [data-thread-id]'); if (parent) { const parentThreadId = parent.getAttribute('data-legacy-thread-id') || parent.getAttribute('data-thread-id'); if (parentThreadId && this.validateThreadId(parentThreadId)) { return parentThreadId; } } } return null; } // Method 5: Extract from browser history extractFromBrowserHistory() { // Check if we can extract from the current history state if (history.state && history.state.threadId) { return history.state.threadId; } // Parse previous URLs in session storage try { const historyKey = 'gmail_thread_history'; let previousUrls = JSON.parse(sessionStorage.getItem(historyKey) || '[]'); // Add current URL to history const currentUrl = window.location.href; if (!previousUrls.includes(currentUrl)) { previousUrls.push(currentUrl); // Keep only last 50 URLs if (previousUrls.length > 50) { previousUrls = previousUrls.slice(-50); } sessionStorage.setItem(historyKey, JSON.stringify(previousUrls)); } // Search through history for (const url of previousUrls.reverse()) { const match = url.match(/[#/]([a-f0-9]{16})/i); if (match && this.validateThreadId(match[1])) { return match[1]; } } } catch (error) { console.warn('Could not parse thread history'); } return null; } // Method 6: Extract from Gmail internals extractFromGmailInternals() { // Try to access Gmail's internal JavaScript objects try { // Gmail sometimes exposes thread data in global variables if (window.GM_SPT_ENABLED && window.GM_ACTION_TOKEN) { // Look for Gmail's internal state const scripts = document.querySelectorAll('script'); for (const script of scripts) { if (script.textContent.includes('thread_id')) { const match = script.textContent.match(/"thread_id":"([a-f0-9]{16})"/i); if (match && this.validateThreadId(match[1])) { return match[1]; } } } } // Check for thread ID in Gmail's data layer if (window.dataLayer) { for (const item of window.dataLayer) { if (item.thread_id && this.validateThreadId(item.thread_id)) { return item.thread_id; } } } // Check for Gmail's GLOBALS object if (window.GLOBALS) { // Gmail sometimes stores thread info in GLOBALS const threadIdPatterns = [ window.GLOBALS[17], // Thread ID location in some versions window.GLOBALS[40], // Alternative location ]; for (const value of threadIdPatterns) { if (value && typeof value === 'string' && this.validateThreadId(value)) { return value; } } } } catch (error) { console.warn('Gmail internals extraction failed:', error); } return null; } // Method 7: Fallback methods async extractFromFallbackMethods(context) { // Try to trigger Gmail to reveal thread ID await this.triggerGmailStateUpdate(); // Wait a bit and try DOM extraction again await new Promise(resolve => setTimeout(resolve, 500)); const domResult = this.extractFromDOM(context); if (domResult) return domResult; // Try to extract from any visible thread indicators const threadIndicators = document.querySelectorAll('[title*="thread"], [aria-label*="thread"]'); for (const indicator of threadIndicators) { const text = indicator.title || indicator.getAttribute('aria-label') || ''; const match = text.match(/([a-f0-9]{16})/i); if (match && this.validateThreadId(match[1])) { return match[1]; } } return null; } // Emergency extraction when all else fails async emergencyExtraction(context) { console.log('Attempting emergency thread ID extraction...'); // Method 1: Force Gmail to navigate and extract from URL change if (context === 'current') { const currentUrl = window.location.href; // Try clicking on the current conversation to force URL update const conversationElement = document.querySelector('.zA.zE, .zA.yW, div[role="listitem"]'); if (conversationElement) { conversationElement.click(); await new Promise(resolve => setTimeout(resolve, 1000)); const newThreadId = this.extractFromURL(); if (newThreadId) return newThreadId; // Restore original state if possible if (window.location.href !== currentUrl) { history.back(); } } } // Method 2: Use MutationObserver to catch thread ID when it appears return new Promise((resolve) => { const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { const threadId = this.extractThreadIdFromElement(node); if (threadId) { observer.disconnect(); resolve(threadId); return; } } } } }); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-legacy-thread-id', 'data-thread-id', 'data-thread-perm-id'] }); // Timeout after 5 seconds setTimeout(() => { observer.disconnect(); resolve(null); }, 5000); }); } // Helper: Extract thread ID by clicking (for batch operations) extractByClicking(row) { try { // Store current URL const originalUrl = window.location.href; // Click the row row.click(); // Wait for navigation const startTime = Date.now(); while (Date.now() - startTime < 1000) { if (window.location.href !== originalUrl) { const threadId = this.extractFromURL(); if (threadId) { // Go back to inbox history.back(); return threadId; } } } } catch (error) { console.warn('Click extraction failed:', error); } return null; } // Helper: Extract thread ID from any element extractThreadIdFromElement(element) { if (!element.querySelector) return null; const threadElement = element.querySelector('[data-legacy-thread-id], [data-thread-id], [data-thread-perm-id]'); if (threadElement) { const threadId = threadElement.getAttribute('data-legacy-thread-id') || threadElement.getAttribute('data-thread-id') || threadElement.getAttribute('data-thread-perm-id'); if (threadId && this.validateThreadId(threadId)) { return threadId; } } return null; } // Helper: Extract message ID from current view extractMessageId() { const selectors = [ '[data-legacy-message-id]', '[data-message-id]', '[data-internaldate]', // Sometimes message ID is in internal date element 'div[role="listitem"][id]' // Message container with ID ]; for (const selector of selectors) { const elements = document.querySelectorAll(selector); for (const element of elements) { const messageId = element.getAttribute('data-legacy-message-id') || element.getAttribute('data-message-id') || element.id; if (messageId && messageId.length > 10) { // Basic validation return messageId; } } } return null; } // Helper: Trigger Gmail to update its state async triggerGmailStateUpdate() { // Trigger events that might cause Gmail to update its DOM const events = ['focus', 'click', 'keydown']; for (const eventType of events) { document.dispatchEvent(new Event(eventType, { bubbles: true })); await new Promise(resolve => setTimeout(resolve, 100)); } // Try pressing 'j' then 'k' to navigate messages document.dispatchEvent(new KeyboardEvent('keydown', { key: 'j' })); await new Promise(resolve => setTimeout(resolve, 200)); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k' })); } // Validate thread ID format validateThreadId(threadId) { if (!threadId || typeof threadId !== 'string') return false; // Gmail thread IDs are typically 16 character hexadecimal strings return /^[a-f0-9]{16}$/i.test(threadId); } // Clear cache clearCache() { this.cache.clear(); } // Set up continuous monitoring startMonitoring() { // Monitor URL changes let lastUrl = window.location.href; const urlObserver = new MutationObserver(() => { if (window.location.href !== lastUrl) { lastUrl = window.location.href; this.clearCache(); // Clear cache on navigation // Store URL in history try { const historyKey = 'gmail_thread_history'; let urls = JSON.parse(sessionStorage.getItem(historyKey) || '[]'); if (!urls.includes(window.location.href)) { urls.push(window.location.href); if (urls.length > 50) urls = urls.slice(-50); sessionStorage.setItem(historyKey, JSON.stringify(urls)); } } catch (e) { console.warn('Failed to update thread history:', e); } } }); urlObserver.observe(document.body, { childList: true, subtree: true }); this.observers.add(urlObserver); // Monitor for new thread elements const threadObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { const threadId = this.extractThreadIdFromElement(node); if (threadId && threadId !== this.lastKnownThreadId) { this.lastKnownThreadId = threadId; // Dispatch custom event for other parts of the extension document.dispatchEvent(new CustomEvent('threadIdDetected', { detail: { threadId } })); } } } } }); threadObserver.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-legacy-thread-id', 'data-thread-id', 'data-thread-perm-id'] }); this.observers.add(threadObserver); console.log('Thread monitoring started'); } // Clean up observers stopMonitoring() { for (const observer of this.observers) { observer.disconnect(); } this.observers.clear(); console.log('Thread monitoring stopped'); } } // Create global instance const threadExtractor = new GmailThreadExtractor(); // Start monitoring for thread changes threadExtractor.startMonitoring(); // Extract thread ID with all fallbacks async function getThreadIdReliably(options = {}) { const threadId = await threadExtractor.extractThreadId('current', options); if (!threadId) { throw new Error('Could not extract thread ID after trying all methods'); } return threadId; } // Backward compatibility functions function extractThreadId(context = 'current') { return threadExtractor.extractThreadId(context); } function getSelectedThreadIds() { return threadExtractor.extractThreadId('selected'); } // Export for use in other parts of the extension if (typeof module !== 'undefined' && module.exports) { module.exports = { GmailThreadExtractor, getThreadIdReliably, threadExtractor, extractThreadId, getSelectedThreadIds }; }