// background.js // Import email parser functions importScripts('email_parser.js'); // Enhanced logging function function logWithStyle(message, type = 'info') { const styles = { info: 'color: #1a4d2e; font-weight: bold;', success: 'color: #1a4d2e; font-weight: bold; background: #e6ffe6; padding: 2px 5px; border-radius: 3px;', error: 'color: #cc0000; font-weight: bold; background: #ffe6e6; padding: 2px 5px; border-radius: 3px;', warning: 'color: #e6b800; font-weight: bold; background: #fff9e6; padding: 2px 5px; border-radius: 3px;' }; console.log(`%c${message}`, styles[type]); } // Initialize with enhanced logging logWithStyle('=== GPT Auto-Draft Background Script Initializing ===', 'info'); logWithStyle('Background script loaded successfully', 'success'); logWithStyle('Email parser imported successfully', 'success'); console.log('Background script loaded, email_parser.js imported'); // Add a cached token so we don't hit the OAuth flow on every request let cachedOAuthToken = null; let tokenExpiryTime = null; // Enhanced token handling let tokenRefreshInProgress = false; let tokenRefreshPromise = null; // Rate limiting and API usage tracking const API_RATE_LIMITS = { gmail: { maxRequestsPerMinute: 60, maxRequestsPerHour: 1000 }, openai: { maxRequestsPerMinute: 20, maxRequestsPerHour: 200 } }; const apiUsage = { gmail: { requests: [], lastReset: Date.now() }, openai: { requests: [], lastReset: Date.now() } }; // Rate limiting helper function checkRateLimit(api) { const now = Date.now(); const limits = API_RATE_LIMITS[api]; const usage = apiUsage[api]; // Clean old requests usage.requests = usage.requests.filter(time => now - time < 3600000); // Keep last hour // Check minute limit const minuteRequests = usage.requests.filter(time => now - time < 60000); if (minuteRequests.length >= limits.maxRequestsPerMinute) { throw new Error(`Rate limit exceeded: ${limits.maxRequestsPerMinute} requests per minute for ${api}`); } // Check hour limit if (usage.requests.length >= limits.maxRequestsPerHour) { throw new Error(`Rate limit exceeded: ${limits.maxRequestsPerHour} requests per hour for ${api}`); } // Add new request usage.requests.push(now); } async function getValidToken(forceRefresh = false) { // Check if we need to refresh based on expiry time const now = Date.now(); const shouldRefresh = forceRefresh || !cachedOAuthToken || (tokenExpiryTime && now >= tokenExpiryTime - 60000); // Refresh 1 minute before expiry if (!shouldRefresh && cachedOAuthToken) { return cachedOAuthToken; } // If a refresh is already in progress, wait for it if (tokenRefreshInProgress && tokenRefreshPromise) { return tokenRefreshPromise; } // Start a new refresh tokenRefreshInProgress = true; tokenRefreshPromise = new Promise((resolve, reject) => { chrome.identity.getAuthToken({ interactive: false }, async (token) => { if (chrome.runtime.lastError || !token) { console.warn('Silent token refresh failed, trying interactive...'); // Clear cached token on failure - FIX: Check if token exists before trying to remove if (cachedOAuthToken) { chrome.identity.removeCachedAuthToken({ token: cachedOAuthToken }, () => { chrome.identity.getAuthToken({ interactive: true }, (interactiveToken) => { if (chrome.runtime.lastError || !interactiveToken) { console.error('Token refresh failed:', chrome.runtime.lastError); cachedOAuthToken = null; tokenExpiryTime = null; tokenRefreshInProgress = false; tokenRefreshPromise = null; reject(chrome.runtime.lastError || new Error('Failed to get token')); } else { cachedOAuthToken = interactiveToken; // Set expiry to 50 minutes from now (tokens typically last 1 hour) tokenExpiryTime = Date.now() + (50 * 60 * 1000); tokenRefreshInProgress = false; tokenRefreshPromise = null; logWithStyle('Token refreshed successfully (interactive)', 'success'); resolve(interactiveToken); } }); }); } else { // No cached token to remove, go straight to interactive auth chrome.identity.getAuthToken({ interactive: true }, (interactiveToken) => { if (chrome.runtime.lastError || !interactiveToken) { console.error('Token refresh failed:', chrome.runtime.lastError); cachedOAuthToken = null; tokenExpiryTime = null; tokenRefreshInProgress = false; tokenRefreshPromise = null; reject(chrome.runtime.lastError || new Error('Failed to get token')); } else { cachedOAuthToken = interactiveToken; // Set expiry to 50 minutes from now tokenExpiryTime = Date.now() + (50 * 60 * 1000); tokenRefreshInProgress = false; tokenRefreshPromise = null; logWithStyle('Token refreshed successfully (interactive)', 'success'); resolve(interactiveToken); } }); } } else { cachedOAuthToken = token; // Set expiry to 50 minutes from now tokenExpiryTime = Date.now() + (50 * 60 * 1000); tokenRefreshInProgress = false; tokenRefreshPromise = null; logWithStyle('Token refreshed successfully (silent)', 'success'); resolve(token); } }); }); return tokenRefreshPromise; } // Enhanced API request wrapper with rate limiting async function makeGmailApiRequest(url, options = {}) { try { checkRateLimit('gmail'); let token = await getValidToken(); let response = await fetch(url, { ...options, headers: { ...options.headers, 'Authorization': `Bearer ${token}` } }); // If unauthorized, try refreshing token once if (response.status === 401) { console.log('Token expired (401), refreshing...'); // Remove the expired token from cache - FIX: Check if token exists first if (token) { chrome.identity.removeCachedAuthToken({ token }, async () => { cachedOAuthToken = null; tokenExpiryTime = null; // Get a fresh token token = await getValidToken(true); // Retry the request response = await fetch(url, { ...options, headers: { ...options.headers, 'Authorization': `Bearer ${token}` } }); }); } else { // No token to remove, just get a fresh one cachedOAuthToken = null; tokenExpiryTime = null; token = await getValidToken(true); // Retry the request response = await fetch(url, { ...options, headers: { ...options.headers, 'Authorization': `Bearer ${token}` } }); } } if (!response.ok) { const errorText = await response.text(); console.error(`Gmail API request failed: ${response.status} ${response.statusText}`, errorText); throw new Error(`Gmail API request failed: ${response.status} ${response.statusText}`); } return response; } catch (error) { console.error('Gmail API request failed:', error); throw error; } } // Data validation helpers function validateThreadData(threadData) { if (!threadData) throw new Error('Thread data is required'); if (!Array.isArray(threadData.messages)) throw new Error('Thread messages must be an array'); if (threadData.messages.length === 0) throw new Error('Thread must contain at least one message'); // No sender validation at all } function validateDraftData(draftData) { if (!draftData) throw new Error('Draft data is required'); if (!draftData.recipient) throw new Error('Draft missing recipient'); if (!draftData.subject) throw new Error('Draft missing subject'); if (!draftData.content) throw new Error('Draft missing content'); } // Enhanced message listener with validation and feedback chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { console.log('Received message:', message); if (message.action === 'generateDraft') { try { // Validate input if (!message.threadMessages) { throw new Error('Thread messages are required'); } // Send progress update chrome.tabs.sendMessage(sender.tab.id, { action: 'updateProgress', status: 'Generating draft...' }); handleGenerateDraft(message.threadMessages, message.contextPrompt) .then(draft => { console.log('Draft generated successfully:', draft); chrome.tabs.sendMessage(sender.tab.id, { action: 'updateProgress', status: 'Draft generated successfully' }); sendResponse({ draft }); }) .catch(error => { console.error('Drafting failed:', error); chrome.tabs.sendMessage(sender.tab.id, { action: 'updateProgress', status: 'Error: ' + error.message, error: true }); sendResponse({ error: error.message || 'Drafting failed' }); }); return true; } catch (error) { console.error('Validation failed:', error); sendResponse({ error: error.message }); return false; } } else if (message.action === 'parseMimeEmail') { try { console.log('Parsing MIME email from content script request'); console.log('MIME content length:', message.mimeContent.length); console.log('MIME content preview:', message.mimeContent.substring(0, 100) + '...'); const emailData = extractEmailThread(message.mimeContent); console.log('Email parsed successfully:', emailData); console.log('Subject:', emailData.subject); console.log('From:', emailData.sender.name, '<' + emailData.sender.email + '>'); console.log('To:', emailData.recipient.name, '<' + emailData.recipient.email + '>'); console.log('Content length:', emailData.content.length); sendResponse({ success: true, emailData }); } catch (error) { console.error('Email parsing failed:', error); sendResponse({ success: false, error: error.message || 'Email parsing failed' }); } return true; // async } else if (message.action === 'getOAuthToken') { getValidToken() .then(token => sendResponse({ token })) .catch(error => sendResponse({ error: error.message })); return true; } }); // Utility: Format the entire thread as a single string function formatThreadAsString(messages) { console.log('Formatting thread as string'); console.log('Thread messages type:', typeof messages); if (!Array.isArray(messages)) { console.log('Thread messages is not an array, converting to string'); return messages ? String(messages).trim() : ''; } console.log('Thread contains', messages.length, 'messages'); // Format each message with sender, timestamp, and content return messages.map(msg => `${msg.senderName} <${msg.sender}>\n${msg.timestamp}\n\n${msg.content}` ).join('\n---\n'); } // Helper: Summarize and truncate thread for OpenAI API function prepareThreadForOpenAI(messages, maxChars = 250000) { console.log('\n=== PREPARE THREAD FOR OPENAI ==='); console.log('Input messages:', Array.isArray(messages) ? messages.length : 'Not an array'); console.log('Max characters allowed:', maxChars); if (!Array.isArray(messages)) { console.log('Messages is not an array, returning as string'); return String(messages); } // Log structure of first message for debugging if (messages.length > 0) { console.log('First message structure:'); console.log(JSON.stringify(messages[0], null, 2)); } // Format each message with clear structure const formatMessage = (msg, index) => { const sender = msg.senderName || msg.from || 'Unknown'; const email = msg.sender || msg.from || 'unknown@email.com'; const timestamp = msg.timestamp || msg.date || 'No timestamp'; let content = msg.content || ''; // Clean up content - remove excessive whitespace but preserve code blocks content = content .replace(/\[CODE BLOCK\]/g, '\n```') .replace(/\[\/CODE BLOCK\]/g, '```\n') .trim(); // Format the message with clear boundaries const formatted = [ `=== Message ${index + 1} ===`, `From: ${sender} <${email}>`, `Date: ${timestamp}`, '', content, '' ].join('\n'); console.log(`Message ${index + 1} formatted length:`, formatted.length); return formatted; }; // If the thread is short enough, return as is with nice formatting let fullText = messages.map(formatMessage).join('\n---\n'); console.log('Full formatted text length:', fullText.length); if (fullText.length <= maxChars) { console.log('Thread is within max chars, returning full text'); return fullText; } console.log('Thread exceeds max chars, summarizing...'); // Otherwise, include recent messages in full and summarize older ones let result = ''; let recentMessages = []; let totalLength = 0; // Start from the most recent message and work backwards for (let i = messages.length - 1; i >= 0; i--) { const formatted = formatMessage(messages[i], i); if (totalLength + formatted.length + 100 > maxChars * 0.8) { // We've hit our limit break; } recentMessages.unshift(formatted); totalLength += formatted.length + 10; // Account for separator } // Add summary header if we didn't include all messages if (recentMessages.length < messages.length) { const omittedCount = messages.length - recentMessages.length; result = `[Note: ${omittedCount} earlier messages omitted for length. Showing ${recentMessages.length} most recent messages.]\n\n`; console.log(`Summarized ${omittedCount} earlier messages`); } result += recentMessages.join('\n---\n'); console.log('Final thread length:', result.length); console.log('=== END PREPARE THREAD FOR OPENAI ===\n'); return result; } async function handleGenerateDraft(threadMessages, contextPrompt = null) { console.log('=== HANDLE GENERATE DRAFT ==='); console.log('handleGenerateDraft called with:', { threadMessages, contextPrompt }); try { checkRateLimit('openai'); const settings = await getFromStorage(['openAIApiKey', 'customPrompt', 'customGptEndpoint']); if (!settings.openAIApiKey) throw new Error('OpenAI API key not set'); if (!settings.customGptEndpoint) throw new Error('Assistant ID not set'); // Validate thread data if (Array.isArray(threadMessages)) { validateThreadData({ messages: threadMessages }); } const apiKey = settings.openAIApiKey; const assistantId = settings.customGptEndpoint || ''; const basePrompt = settings.customPrompt || ''; console.log('\n=== SETTINGS ==='); console.log('Assistant ID:', assistantId); console.log('Base Prompt:', basePrompt); console.log('Context Prompt:', contextPrompt); // Prepare thread for OpenAI API (truncate/summarize if needed) let formattedThread = ''; if (typeof threadMessages === 'string' && threadMessages.startsWith('MIME-Version:')) { console.log('Detected MIME email format, parsing...'); try { const emailData = extractEmailThread(threadMessages); console.log('MIME email parsed successfully'); console.log('Email data:', { subject: emailData.subject, sender: emailData.sender, recipient: emailData.recipient, contentLength: emailData.content.length }); formattedThread = `From: ${emailData.sender.name} <${emailData.sender.email}>\n` + `To: ${emailData.recipient.name} <${emailData.recipient.email}>\n` + `Subject: ${emailData.subject}\n` + `Date: ${emailData.date}\n\n` + `${emailData.content}`; console.log('Formatted thread length:', formattedThread.length); } catch (error) { console.error('Failed to parse MIME email:', error); console.log('Falling back to raw content'); formattedThread = threadMessages; // Fallback to raw content } } else { // Use the new truncation/summarization helper formattedThread = prepareThreadForOpenAI(threadMessages); console.log('\n=== FORMATTED THREAD ==='); console.log('Formatted thread length:', formattedThread.length); console.log('Formatted thread preview:'); console.log(formattedThread.substring(0, 1000)); if (formattedThread.length > 1000) { console.log('... [truncated for console] ...'); } } if (!apiKey) { console.error('API key not set.'); throw new Error('API key not set.'); } if (!assistantId) { console.error('Assistant ID not set.'); throw new Error('Assistant ID not set.'); } // Always include the base prompt, and append the context prompt if present let promptToUse = basePrompt; if (c 30 June 2025 at 05:00 ontextPrompt && contextPrompt.trim()) { console.log('Adding context-specific prompt:', contextPrompt.trim()); promptToUse += '\n\nAdditional instructions: ' + contextPrompt.trim(); } const messageContent = `Instructions: ${promptToUse}\n\nEmail Thread:\n${formattedThread}`; console.log('\n=== FINAL PAYLOAD TO OPENAI ==='); console.log('Using combined prompt:', promptToUse); console.log('Total message content length:', messageContent.length); console.log('\nFull message content being sent to OpenAI:'); console.log('------------------------'); console.log(messageContent); console.log('------------------------\n'); // 1. Create a thread console.log('Creating OpenAI thread...'); const threadResponse = await fetch('https://api.openai.com/v1/threads', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, 'OpenAI-Beta': 'assistants=v2' } }); if (!threadResponse.ok) { const errorText = await threadResponse.text(); console.error('Failed to create thread:', threadResponse.status, errorText); throw new Error('Failed to create thread'); } const thread = await threadResponse.json(); console.log('Thread created with ID:', thread.id); // 2. Add a message to the thread with the prompt and thread content console.log('\nAdding message to thread...'); console.log('Message payload:'); console.log(JSON.stringify({ role: 'user', content: messageContent }, null, 2)); const messageResponse = await fetch(`https://api.openai.com/v1/threads/${thread.id}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, 'OpenAI-Beta': 'assistants=v2' }, body: JSON.stringify({ role: 'user', content: messageContent }) }); if (!messageResponse.ok) { const errorText = await messageResponse.text(); console.error('Failed to add message to thread:', messageResponse.status, errorText); throw new Error('Failed to add message to thread'); } const message = await messageResponse.json(); console.log('Message added to thread with ID:', message.id); // 3. Run the assistant console.log('\nRunning assistant with ID:', assistantId); const runResponse = await fetch(`https://api.openai.com/v1/threads/${thread.id}/runs`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, 'OpenAI-Beta': 'assistants=v2' }, body: JSON.stringify({ assistant_id: assistantId }) }); if (!runResponse.ok) { const errorText = await runResponse.text(); console.error('Failed to start run:', runResponse.status, errorText); throw new Error('Failed to start run'); } const run = await runResponse.json(); console.log('Run started with ID:', run.id); // 4. Poll for completion console.log('\nPolling for run completion...'); let completed = false; let response = null; let pollCount = 0; while (!completed) { pollCount++; console.log(`Polling attempt ${pollCount}...`); const statusResponse = await fetch(`https://api.openai.com/v1/threads/${thread.id}/runs/${run.id}`, { headers: { 'Authorization': `Bearer ${apiKey}`, 'OpenAI-Beta': 'assistants=v2' } }); if (!statusResponse.ok) { const errorText = await statusResponse.text(); console.error('Failed to check run status:', statusResponse.status, errorText); throw new Error('Failed to check run status'); } const status = await statusResponse.json(); console.log('Run status:', status.status); if (status.status === 'completed') { console.log('Run completed successfully'); completed = true; // 5. Get the messages console.log('Retrieving messages...'); const messagesResponse = await fetch(`https://api.openai.com/v1/threads/${thread.id}/messages`, { headers: { 'Authorization': `Bearer ${apiKey}`, 'OpenAI-Beta': 'assistants=v2' } }); if (!messagesResponse.ok) { const errorText = await messagesResponse.text(); console.error('Failed to get messages:', messagesResponse.status, errorText); throw new Error('Failed to get messages'); } const messages = await messagesResponse.json(); console.log('Retrieved', messages.data.length, 'messages'); console.log('\n=== OPENAI RESPONSE ==='); console.log('Full messages response:', JSON.stringify(messages, null, 2)); response = messages.data[0].content[0].text.value; console.log('\nExtracted response:'); console.log(response); console.log('Response length:', response.length); console.log('=== END OPENAI RESPONSE ===\n'); } else if (status.status === 'failed') { console.error('Run failed:', status.last_error); throw new Error('Run failed: ' + (status.last_error?.message || 'Unknown error')); } else { // Wait before checking again console.log('Run still in progress, waiting before next poll...'); await new Promise(resolve => setTimeout(resolve, 1000)); } } console.log('=== END HANDLE GENERATE DRAFT ===\n'); return response; } catch (error) { console.error('Draft generation failed:', error); throw error; } } function getFromStorage(keys) { return new Promise((resolve) => { chrome.storage.local.get(keys, resolve); }); } // Handle extension icon click chrome.action.onClicked.addListener(async (tab) => { // Check if we're on Gmail, Instantly, or Plusvibe/Pipl if (!tab.url.includes('mail.google.com') && !tab.url.includes('app.instantly.ai') && !tab.url.includes('app.pipl.ai')) { console.log('Extension clicked on unsupported tab, ignoring'); return; } console.log('Extension icon clicked, toggling UI on:', tab.url); // Send message to content script to toggle UI visibility chrome.tabs.sendMessage(tab.id, { action: 'toggleUI' }); });