gpt_chrome_autodrafter_v3/content.js

2301 lines
79 KiB
JavaScript
Raw Normal View History

2025-07-01 15:46:34 -07:00
// content.js
// Color scheme from newfrontierfunding.com
const COLORS = {
green: '#1a4d2e',
gold: '#e6b800',
white: '#ffffff',
};
// 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 Extension Initializing ===', 'info');
logWithStyle('Content script loaded successfully', 'success');
// Load enhanced thread extractor
const script = document.createElement('script');
script.src = chrome.runtime.getURL('enhanced_thread_extractor.js');
script.onload = function() {
logWithStyle('Enhanced thread extractor loaded successfully', 'success');
logWithStyle('Thread monitoring started - 7+ extraction methods active', 'info');
};
script.onerror = function() {
logWithStyle('Failed to load enhanced thread extractor!', 'error');
};
(document.head || document.documentElement).appendChild(script);
// Utility: Wait for an element to appear
function waitForElement(selector, timeout = 10000) {
console.log('Waiting for element:', selector);
return new Promise((resolve, reject) => {
const interval = 100;
let elapsed = 0;
const check = () => {
const el = document.querySelector(selector);
if (el) {
console.log('Element found:', selector);
return resolve(el);
}
elapsed += interval;
if (elapsed >= timeout) {
console.warn('Element not found after timeout:', selector);
return reject('Element not found: ' + selector);
}
setTimeout(check, interval);
};
check();
});
}
// Utility: Get settings from storage
function getSettings() {
console.log('Getting settings from storage');
return new Promise((resolve) => {
chrome.storage.local.get([
'autoDraft', 'openAIApiKey', 'customPrompt'
], (settings) => {
console.log('Settings loaded:', {
autoDraftEnabled: !!settings.autoDraft,
apiKeyExists: !!settings.openAIApiKey,
customPromptExists: !!settings.customPrompt
});
resolve(settings);
});
});
}
// Detect Gmail reply box
function findReplyBox() {
// Try the standard Gmail reply/compose box
let replyBox = document.querySelector('div[aria-label="Message Body"]');
if (replyBox) {
console.log('Reply box found with aria-label="Message Body"');
return replyBox;
}
// Fallback: look for contenteditable divs that are visible and editable
const candidates = Array.from(document.querySelectorAll('div[contenteditable="true"]'));
for (const el of candidates) {
// Exclude hidden or offscreen elements
const style = window.getComputedStyle(el);
if (style.display !== 'none' && style.visibility !== 'hidden' && el.offsetParent !== null) {
// Optionally, check for a minimum size to avoid toolbars, etc.
if (el.offsetHeight > 30 && el.offsetWidth > 100) {
console.log('Reply box found with contenteditable fallback');
return el;
}
}
}
// Fallback: look for Gmail's .editable class (older or alternate layouts)
replyBox = document.querySelector('div.editable');
if (replyBox) {
console.log('Reply box found with .editable class');
return replyBox;
}
// If not found, log a warning
console.warn('Reply box not found! Selectors tried: [aria-label="Message Body"], [contenteditable="true"], .editable');
return null;
}
// Patch: Only look for reply box if not on inbox page
function safeFindReplyBox() {
if (isInboxPage()) return null;
return findReplyBox();
}
// Request draft from background
let draftCooldownUntil = 0; // timestamp in ms
function requestDraft(threadMessages, contextPrompt = null) {
console.log('=== DRAFT REQUEST PAYLOAD ===');
console.log('Requesting draft from background script');
console.log('Thread messages type:', typeof threadMessages);
// Detailed logging of thread messages
if (typeof threadMessages === 'string') {
console.log('Thread message length:', threadMessages.length);
console.log('Thread message preview:', threadMessages.substring(0, 100) + '...');
console.log('Full thread message string:', threadMessages);
} else if (Array.isArray(threadMessages)) {
console.log('Thread contains', threadMessages.length, 'messages');
console.log('Thread messages structure:');
threadMessages.forEach((msg, index) => {
console.log(`\n--- Message ${index + 1} ---`);
console.log('From:', msg.from || msg.sender);
console.log('To:', msg.to);
console.log('Subject:', msg.subject);
console.log('Date:', msg.date);
console.log('Content length:', msg.content ? msg.content.length : 0);
console.log('Content preview:', msg.content ? msg.content.substring(0, 200) + '...' : 'No content');
if (msg.content && msg.content.length < 500) {
console.log('Full content:', msg.content);
}
});
}
console.log('\nContext prompt:', contextPrompt);
// Log the exact payload being sent
const payload = {
action: 'generateDraft',
threadMessages,
contextPrompt
};
console.log('\nFull payload being sent to background:');
console.log(JSON.stringify(payload, null, 2));
console.log('=== END DRAFT REQUEST PAYLOAD ===\n');
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(payload, (response) => {
console.log('=== DRAFT RESPONSE ===');
if (response && response.draft) {
console.log('Draft received from background, length:', response.draft.length);
console.log('Draft preview:', response.draft.substring(0, 200) + '...');
console.log('Full draft:', response.draft);
resolve(response.draft);
}
else if (response && response.error && response.error.includes('429')) {
console.error('Draft request failed with rate limit error:', response.error);
reject({ code: 429, message: response.error });
}
else {
console.error('Draft request failed:', response.error || 'No response');
reject(response && response.error ? response.error : 'No draft');
}
console.log('=== END DRAFT RESPONSE ===\n');
});
});
}
// Parse MIME email
function parseMimeEmail(mimeContent) {
console.log('Sending MIME content to background for parsing');
console.log('MIME content length:', mimeContent.length);
console.log('MIME content preview:', mimeContent.substring(0, 100) + '...');
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({
action: 'parseMimeEmail',
mimeContent
}, (response) => {
if (response && response.success) {
console.log('MIME email parsed successfully');
console.log('Parsed email data:', {
subject: response.emailData.subject,
sender: response.emailData.sender,
recipient: response.emailData.recipient,
contentLength: response.emailData.content.length
});
resolve(response.emailData);
}
else {
console.error('MIME parsing failed:', response && response.error ? response.error : 'Unknown error');
reject(response && response.error ? response.error : 'Failed to parse email');
}
});
});
}
// Track UI visibility state
let isUIVisible = false;
let uiContainer = null;
let currentThreadMessages = '';
let observer = null; // Add observer reference
// Function to update thread messages
function updateThreadMessages() {
console.log('Updating thread messages');
const messageElements = document.querySelectorAll('div[role="listitem"]');
console.log('Found', messageElements.length, 'message elements');
// Get thread ID from URL or data attribute
const threadId = (() => {
const urlMatch = window.location.pathname.match(/\/d\/[a-zA-Z0-9]+/);
if (urlMatch) return urlMatch[0].split('/')[2];
const threadElement = document.querySelector('[data-thread-id]');
return threadElement ? threadElement.getAttribute('data-thread-id') : null;
})();
const messages = Array.from(messageElements).map((item, index) => {
const senderSpan = item.querySelector('span[email]');
const sender = senderSpan ? senderSpan.getAttribute('email') || 'Unknown' : 'Unknown';
const senderName = senderSpan ? senderSpan.textContent || 'Unknown' : 'Unknown';
const timestamp = item.querySelector('span[title]')?.getAttribute('title') || '';
const messageId = item.getAttribute('data-message-id') || null;
console.log(`Processing message ${index} from`, senderName || sender);
const content = (() => {
let bodyDiv = item.querySelector('div[dir="ltr"], div[dir="auto"]');
if (!bodyDiv) bodyDiv = item.querySelector('.a3s'); // fallback
if (!bodyDiv) {
if (item.querySelector('.ajR')) {
console.warn(`Message ${index} appears collapsed (trimmed). Expand to view full content`);
return '[Message collapsed. Please expand to view full content.]';
}
console.warn(`Message ${index}: No message body found`);
return '';
}
const clone = bodyDiv.cloneNode(true);
clone.querySelectorAll('.gmail_quote, blockquote, .gmail_signature').forEach(q => q.remove());
const content = clone.innerText.trim();
console.log(`Message ${index} content length:`, content.length);
return content;
})();
return {
sender,
senderName,
timestamp,
content,
messageId,
isReply: index > 0, // Mark if this is a reply in the thread
threadId // Include thread ID in each message
};
});
currentThreadMessages = messages;
console.log('Thread messages updated, total messages:', messages.length);
return messages;
}
// Function to check if content is a MIME email
function isMimeEmail(content) {
const isMime = typeof content === 'string' &&
content.trim().startsWith('MIME-Version:') &&
content.includes('Content-Type:');
console.log('Checking if content is MIME email:', isMime);
return isMime;
}
// Function to handle MIME email content
async function handleMimeEmail(content) {
console.log('Handling MIME email content');
console.log('Content length:', content.length);
try {
const emailData = await parseMimeEmail(content);
console.log('Parsed MIME email:', emailData);
return emailData;
} catch (error) {
console.error('Failed to parse MIME email:', error);
return null;
}
}
// Function to toggle UI visibility
function toggleUIVisibility() {
console.log('Toggling UI visibility, current state:', isUIVisible);
// If UI is not visible or container is missing, show it
if (!isUIVisible || !uiContainer) {
console.log('Showing UI');
const replyBox = safeFindReplyBox();
if (replyBox) {
updateThreadMessages();
injectUI(replyBox, '', currentThreadMessages);
uiContainer = document.querySelector('.gpt-autodraft-ui');
isUIVisible = true;
console.log('UI injected and visible');
// Start observer if it doesn't exist
if (!observer) {
console.log('Starting DOM observer');
observer = new MutationObserver(async () => {
const replyBox = safeFindReplyBox();
if (replyBox) {
updateThreadMessages();
if (uiContainer) {
const draftBtn = uiContainer.querySelector('button');
if (draftBtn) {
draftBtn.onclick = async () => {
// Cooldown check
if (Date.now() < draftCooldownUntil) {
const errorMsg = uiContainer ? uiContainer.querySelector('div[style*="color: red"]') : null;
if (errorMsg) {
errorMsg.textContent = `Please wait ${(Math.ceil((draftCooldownUntil - Date.now()) / 1000))} seconds before trying again.`;
errorMsg.style.display = 'block';
}
console.log('Draft button clicked during cooldown period');
return;
}
console.log('Draft button clicked');
draftBtn.disabled = true;
const errorMsg = uiContainer ? uiContainer.querySelector('div[style*="color: red"]') : null;
if (errorMsg) errorMsg.style.display = 'none';
try {
const promptInput = document.querySelector('.gpt-autodraft-ui textarea');
const contextPrompt = promptInput ? promptInput.value.trim() : null;
// Use robust Gmail API-based one-off autodraft
await oneOffAutoDraftForCurrentThread(contextPrompt);
// Optionally, show a success message or update UI
} catch (e) {
console.error('Drafting failed:', e);
const errorMsg = uiContainer ? uiContainer.querySelector('div[style*="color: red"]') : null;
if (errorMsg) {
if (e && e.code === 429) {
errorMsg.textContent = 'Too many requests. Please wait 60 seconds.';
errorMsg.style.display = 'block';
draftCooldownUntil = Date.now() + 60000;
draftBtn.disabled = true;
console.log('Rate limit hit, setting cooldown for 60 seconds');
setTimeout(() => {
draftBtn.disabled = false;
if (errorMsg) errorMsg.style.display = 'none';
console.log('Cooldown period ended, re-enabling draft button');
}, 60000);
} else {
errorMsg.textContent = 'Drafting failed. Retrying...';
errorMsg.style.display = 'block';
console.log('Drafting failed, retrying in 2 seconds');
setTimeout(() => draftBtn.onclick(), 2000);
}
}
} finally {
if (Date.now() >= draftCooldownUntil) {
draftBtn.disabled = false;
console.log('Draft button re-enabled');
}
}
};
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
} else {
console.log('No reply box found, cannot show UI');
}
} else {
// Hide the UI
console.log('Hiding UI');
isUIVisible = false;
if (observer) {
observer.disconnect();
observer = null;
console.log('DOM observer disconnected');
}
if (uiContainer) {
uiContainer.remove();
uiContainer = null;
console.log('UI container removed');
}
}
}
// Insert Auto-Draft button and feedback UI
function injectUI(replyBox, draftText, threadMessages) {
console.log('Injecting UI into reply box');
// Prevent duplicate UI
if (replyBox.parentElement.querySelector('.gpt-autodraft-ui')) {
console.log('UI already exists, not injecting duplicate');
return;
}
// Container
const container = document.createElement('div');
container.className = 'gpt-autodraft-ui';
container.style.background = COLORS.white;
container.style.border = `2px solid ${COLORS.green}`;
container.style.padding = '8px';
container.style.margin = '8px 0';
container.style.borderRadius = '8px';
container.style.position = 'fixed';
container.style.bottom = '24px';
container.style.right = '24px';
container.style.zIndex = '99999';
container.style.boxShadow = '0 2px 12px rgba(0,0,0,0.15)';
// Custom prompt input
const promptLabel = document.createElement('label');
promptLabel.textContent = 'Context-Specific Prompt:';
promptLabel.style.color = COLORS.green;
promptLabel.style.display = 'block';
promptLabel.style.marginBottom = '4px';
const promptInput = document.createElement('textarea');
promptInput.style.width = '100%';
promptInput.style.minHeight = '24px';
promptInput.style.height = 'auto';
promptInput.style.maxHeight = '240px';
promptInput.style.overflowY = 'auto';
promptInput.style.marginBottom = '8px';
promptInput.style.border = `1px solid ${COLORS.gold}`;
promptInput.style.borderRadius = '4px';
promptInput.style.padding = '4px';
promptInput.style.paddingRight = '0px';
promptInput.style.resize = 'vertical';
// Auto-expand textarea
promptInput.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = (this.scrollHeight) + 'px';
});
// Auto-Draft button
const draftBtn = document.createElement('button');
draftBtn.textContent = 'Auto-Draft with GPT';
draftBtn.style.background = COLORS.green;
draftBtn.style.color = COLORS.white;
draftBtn.style.border = 'none';
draftBtn.style.padding = '6px 12px';
draftBtn.style.borderRadius = '4px';
draftBtn.style.cursor = 'pointer';
draftBtn.style.width = '100%';
draftBtn.style.marginBottom = '8px'; // Add margin to separate from other elements
// Error message
const errorMsg = document.createElement('div');
errorMsg.style.color = 'red';
errorMsg.style.marginTop = '4px';
errorMsg.style.display = 'none';
errorMsg.style.marginRight = '40px';
// Add elements (no MIME box)
container.appendChild(promptLabel);
container.appendChild(promptInput);
container.appendChild(draftBtn);
container.appendChild(errorMsg);
// Move toggleBtn creation and styling just after container creation for better stacking
const toggleBtn = document.createElement('button');
toggleBtn.textContent = '–';
toggleBtn.title = 'Minimize';
toggleBtn.style.position = 'absolute';
toggleBtn.style.top = '8px';
toggleBtn.style.right = '8px';
toggleBtn.style.background = COLORS.gold;
toggleBtn.style.color = COLORS.green;
toggleBtn.style.border = 'none';
toggleBtn.style.borderRadius = '50%';
toggleBtn.style.width = '28px';
toggleBtn.style.height = '28px';
toggleBtn.style.cursor = 'pointer';
toggleBtn.style.fontWeight = 'bold';
toggleBtn.style.fontSize = '18px';
toggleBtn.style.zIndex = '100000'; // Ensure above other elements
container.appendChild(toggleBtn);
let minimized = false;
function setPromptInputPadding(isMinimized) {
if (isMinimized) {
promptInput.style.paddingRight = '';
promptInput.style.width = '0';
} else {
promptInput.style.paddingRight = '0px';
promptInput.style.width = '100%';
}
}
setPromptInputPadding(false);
toggleBtn.onclick = () => {
minimized = !minimized;
if (minimized) {
promptLabel.style.display = 'none';
promptInput.style.display = 'none';
errorMsg.style.display = 'none';
container.style.height = '44px';
container.style.width = '340px';
container.style.minWidth = '340px';
container.style.padding = '0 12px 0 16px';
container.style.display = 'flex';
container.style.alignItems = 'center';
container.style.justifyContent = 'flex-end';
toggleBtn.textContent = '+';
toggleBtn.title = 'Maximize';
toggleBtn.style.position = 'static';
toggleBtn.style.marginLeft = 'auto';
toggleBtn.style.marginRight = '0';
toggleBtn.style.top = '';
toggleBtn.style.right = '';
toggleBtn.style.display = 'block';
toggleBtn.style.background = COLORS.gold;
toggleBtn.style.zIndex = '100000';
setPromptInputPadding(true);
} else {
promptLabel.style.display = 'block';
promptInput.style.display = 'block';
container.style.width = '';
container.style.minWidth = '';
container.style.height = '';
container.style.padding = '8px';
container.style.display = '';
container.style.alignItems = '';
container.style.justifyContent = '';
toggleBtn.textContent = '–';
toggleBtn.title = 'Minimize';
toggleBtn.style.position = 'absolute';
toggleBtn.style.top = '8px';
toggleBtn.style.right = '8px';
toggleBtn.style.margin = '';
toggleBtn.style.display = 'block';
toggleBtn.style.background = COLORS.gold;
toggleBtn.style.zIndex = '100000';
setPromptInputPadding(false);
}
};
// Draft button logic (remove MIME logic)
draftBtn.onclick = async () => {
console.log('Draft button clicked');
// Cooldown check
if (Date.now() < draftCooldownUntil) {
errorMsg.textContent = `Please wait ${(Math.ceil((draftCooldownUntil - Date.now()) / 1000))} seconds before trying again.`;
errorMsg.style.display = 'block';
console.log('Draft button clicked during cooldown period');
return;
}
draftBtn.disabled = true;
errorMsg.style.display = 'none';
try {
// Only use threadMessages and context prompt
const threadContent = threadMessages;
const contextPrompt = promptInput.value.trim();
const draftPrompt = threadContent ? contextPrompt :
'Write a professional email draft based on the following context: ' + contextPrompt;
const draft = await requestDraft(threadContent || '', draftPrompt);
if (threadContent) {
const recipientName = extractRecipientName();
const formattedDraft = formatEmailReplySmart(draft, recipientName);
insertDraftWithRetry(formattedDraft);
} else {
insertDraftWithRetry(draft);
}
} catch (e) {
console.error('Drafting failed:', e);
if (e && e.code === 429) {
errorMsg.textContent = 'Too many requests. Please wait 60 seconds.';
errorMsg.style.display = 'block';
draftCooldownUntil = Date.now() + 60000;
draftBtn.disabled = true;
setTimeout(() => {
draftBtn.disabled = false;
errorMsg.style.display = 'none';
}, 60000);
} else {
errorMsg.textContent = 'Drafting failed. Retrying...';
errorMsg.style.display = 'block';
setTimeout(() => draftBtn.onclick(), 2000);
}
} finally {
if (Date.now() >= draftCooldownUntil) {
draftBtn.disabled = false;
}
}
};
document.body.appendChild(container);
}
// Detect if we're on the main inbox page (any user index)
function isInboxPage() {
// Only match the main inbox, not thread view
// Accepts: #inbox, #inbox/, #inbox?... Rejects: #inbox/<threadId> or #inbox/<label> etc.
const hash = window.location.hash;
// Accepts #inbox, #inbox/, #inbox?foo, but not #inbox/anything-else
return /^#inbox([/?]?(\?|$))/.test(hash);
}
// Get thread content by ID
async function getThreadContent(threadId) {
// Click the thread to open it
const threadElement = document.querySelector(`tr[data-legacy-thread-id="${threadId}"]`);
if (!threadElement) return null;
threadElement.click();
// Wait for thread to load
await new Promise(resolve => setTimeout(resolve, 1000));
const messages = Array.from(document.querySelectorAll('div[role="listitem"]')).map(item => ({
sender: item.querySelector('span[email]')?.textContent || '',
senderName: item.querySelector('span[email]')?.getAttribute('name') || '',
timestamp: item.querySelector('span[title]')?.getAttribute('title') || '',
content: (() => {
const bodyDiv = item.querySelector('div[dir="ltr"]');
if (!bodyDiv) return '';
const clone = bodyDiv.cloneNode(true);
clone.querySelectorAll('.gmail_quote, blockquote').forEach(q => q.remove());
return clone.innerText.trim();
})(),
messageId: item.getAttribute('data-message-id') || null
}));
// Go back to inbox
const backButton = document.querySelector('div[role="button"][title="Back to Inbox"]');
if (backButton) backButton.click();
// Wait for inbox to load
await new Promise(resolve => setTimeout(resolve, 1000));
return messages;
}
// Create draft for a thread
async function createDraftForThread(threadId, threadContent) {
console.log('Creating draft for thread:', threadId);
try {
const token = await getOAuthTokenFromBackground();
if (!token) {
throw new Error('Failed to get OAuth token');
}
// Get the last message in the thread to reply to
const lastMessage = threadContent[threadContent.length - 1];
const userEmail = getCurrentUserEmail() || 'unknown@email.com'; // Get current user's email
const recipient = extractReplyRecipient(threadContent, userEmail);
// Generate the draft content
const draft = await requestDraft(threadContent);
if (!draft) {
throw new Error('Failed to generate draft content');
}
// Format the draft with proper greeting and signature
const formattedDraft = formatEmailReplySmart(
draft,
recipient.name || 'there',
'Curtis' // You might want to make this configurable
);
// Create the draft in Gmail
const result = await createGmailDraft(
token,
recipient.email,
`Re: ${lastMessage.subject || 'No Subject'}`,
formattedDraft,
threadId,
lastMessage.messageId
);
console.log('Draft created successfully:', result);
return result;
} catch (error) {
console.error('Failed to create draft:', error);
throw error;
}
}
// Add batch draft button as a floating, always-clickable element
function addBatchDraftButton() {
// Remove existing button if any
const existingButton = document.querySelector('.gpt-batch-draft-button');
if (existingButton) existingButton.remove();
// Create button
const button = document.createElement('button');
button.className = 'gpt-batch-draft-button';
button.style.position = 'fixed';
button.style.bottom = '90px'; // Move up to avoid overlap
button.style.right = '32px';
button.style.zIndex = '2147483647'; // max z-index
button.style.background = COLORS.green;
button.style.color = COLORS.white;
button.style.padding = '12px 24px';
button.style.borderRadius = '8px';
button.style.cursor = 'pointer';
button.style.boxShadow = '0 2px 12px rgba(0,0,0,0.25)';
button.style.fontFamily = 'Arial, sans-serif';
button.style.fontSize = '16px';
button.style.fontWeight = 'bold';
button.style.border = '2px solid ' + COLORS.gold;
button.style.pointerEvents = 'auto';
button.style.transition = 'background 0.2s, color 0.2s';
button.textContent = 'Generate Drafts for Selected';
// Add hover effect
button.onmouseenter = () => {
button.style.background = COLORS.gold;
button.style.color = COLORS.green;
};
button.onmouseleave = () => {
button.style.background = COLORS.green;
button.style.color = COLORS.white;
};
// Add click handler (Gmail selected row class)
button.onclick = async () => {
await generateDraftsForThreadsHybrid();
};
// Add to body so it floats above everything
document.body.appendChild(button);
console.log('Batch draft button injected (floating)');
}
// Enhanced thread detection using Gmail API
async function getThreadDetails(token, threadId) {
console.log('Fetching thread details from Gmail API:', threadId);
try {
const response = await fetch(`https://gmail.googleapis.com/gmail/v1/users/me/threads/${threadId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`Failed to fetch thread: ${response.status}`);
}
const thread = await response.json();
console.log('Thread details fetched:', {
id: thread.id,
messageCount: thread.messages?.length || 0,
snippet: thread.snippet
});
// Get full message details for each message in the thread
const messages = await Promise.all(
thread.messages.map(async (message) => {
const msgResponse = await fetch(
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${message.id}`,
{
headers: {
'Authorization': `Bearer ${token}`
}
}
);
if (!msgResponse.ok) {
throw new Error(`Failed to fetch message ${message.id}`);
}
const msgData = await msgResponse.json();
const headers = msgData.payload.headers;
// Enhanced content extraction function
const extractContent = (payload) => {
let textContent = '';
let htmlContent = '';
// Helper to recursively extract content from parts
const extractFromParts = (parts) => {
if (!parts) return;
for (const part of parts) {
const mimeType = part.mimeType;
if (mimeType === 'text/plain' && part.body?.data) {
textContent += atob(part.body.data.replace(/-/g, '+').replace(/_/g, '/')) + '\n';
} else if (mimeType === 'text/html' && part.body?.data) {
htmlContent += atob(part.body.data.replace(/-/g, '+').replace(/_/g, '/')) + '\n';
} else if (part.parts) {
// Recursive call for multipart messages
extractFromParts(part.parts);
}
}
};
// Check if message has parts (multipart)
if (payload.parts) {
extractFromParts(payload.parts);
} else if (payload.body?.data) {
// Single part message
const content = atob(payload.body.data.replace(/-/g, '+').replace(/_/g, '/'));
if (payload.mimeType === 'text/html') {
htmlContent = content;
} else {
textContent = content;
}
}
// Prefer text content, fall back to cleaned HTML
if (textContent.trim()) {
return textContent.trim();
} else if (htmlContent.trim()) {
// Clean HTML to plain text
return cleanHtmlToText(htmlContent);
}
return '';
};
return {
id: message.id,
threadId: thread.id,
headers: headers.reduce((acc, header) => {
acc[header.name.toLowerCase()] = header.value;
return acc;
}, {}),
snippet: msgData.snippet,
rawPayload: msgData.payload,
content: extractContent(msgData.payload)
};
})
);
return {
threadId: thread.id,
messages: messages.map(msg => ({
id: msg.id,
threadId: msg.threadId,
from: msg.headers['from'],
to: msg.headers['to'],
subject: msg.headers['subject'],
date: msg.headers['date'],
messageId: msg.headers['message-id'],
inReplyTo: msg.headers['in-reply-to'],
references: msg.headers['references'],
content: msg.content || msg.snippet || '[No content available]'
}))
};
} catch (error) {
console.error('Error fetching thread details:', error);
throw error;
}
}
// Helper function to clean HTML to plain text
function cleanHtmlToText(html) {
// Create a temporary div to parse HTML
const temp = document.createElement('div');
temp.innerHTML = html;
// Remove script and style elements
const scripts = temp.querySelectorAll('script, style, noscript');
scripts.forEach(el => el.remove());
// Replace code blocks with readable format
const codeBlocks = temp.querySelectorAll('pre, code');
codeBlocks.forEach(block => {
const codeText = block.textContent || '';
block.textContent = `\n[CODE BLOCK]\n${codeText}\n[/CODE BLOCK]\n`;
});
// Replace links with text
const links = temp.querySelectorAll('a');
links.forEach(link => {
const href = link.getAttribute('href');
const text = link.textContent || '';
if (href && !text.includes(href)) {
link.textContent = `${text} (${href})`;
}
});
// Convert br tags to newlines
temp.innerHTML = temp.innerHTML.replace(/<br\s*\/?>/gi, '\n');
// Get text content
let text = temp.textContent || temp.innerText || '';
// Clean up excessive whitespace
text = text
.replace(/\n\s*\n\s*\n/g, '\n\n') // Multiple blank lines to double
.replace(/[ \t]+/g, ' ') // Multiple spaces/tabs to single space
.replace(/^\s+|\s+$/gm, '') // Trim each line
.trim();
return text;
}
// Helper to encode Unicode strings to base64 for Gmail API
function utf8ToBase64(str) {
return btoa(unescape(encodeURIComponent(str)));
}
// Enhanced draft creation with proper threading
async function createGmailDraft(token, to, subject, body, threadId = null, inReplyToMessageId = null) {
console.log('Creating Gmail draft with:', {
to,
subject,
bodyLength: body.length,
threadId,
inReplyToMessageId
});
const headers = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
};
// Proper HTML MIME body
const htmlBody = `<html><body>${body.replace(/\n/g, '<br>')}</body></html>`;
// If we have a thread ID, we're replying to an existing thread
if (threadId) {
console.log('[Draft] About to create reply draft for thread:', threadId, 'subject:', subject);
const threadDetails = await getThreadDetails(token, threadId);
const lastMessage = threadDetails.messages[threadDetails.messages.length - 1];
// Ensure message IDs have angle brackets
const formatMessageId = (msgId) => {
if (!msgId) return '';
// Trim any whitespace
msgId = msgId.trim();
// Check if already has angle brackets
if (msgId.startsWith('<') && msgId.endsWith('>')) {
return msgId;
}
// Add angle brackets if missing
return `<${msgId}>`;
};
// Build complete references header including all message IDs in the thread
let referencesHeader = '';
if (lastMessage.references) {
// If there's already a references header, parse and format it properly
const existingRefs = lastMessage.references
.split(/\s+/)
.map(ref => formatMessageId(ref))
.filter(id => id);
// Add the last message ID if not already included
const lastMsgId = formatMessageId(lastMessage.messageId);
if (lastMsgId && !existingRefs.includes(lastMsgId)) {
existingRefs.push(lastMsgId);
}
referencesHeader = existingRefs.join(' ');
} else {
// Build references from all messages in the thread
const messageIds = threadDetails.messages
.map(msg => formatMessageId(msg.messageId))
.filter(id => id);
referencesHeader = messageIds.join(' ');
}
const inReplyToHeader = formatMessageId(inReplyToMessageId || lastMessage.messageId);
console.log('Setting threading headers:', {
'In-Reply-To': inReplyToHeader,
'References': referencesHeader,
'Thread-ID': threadId,
'Last-Message-ID': lastMessage.messageId,
'Subject': subject
});
// Log all message IDs in the thread for debugging
console.log('All message IDs in thread:');
threadDetails.messages.forEach((msg, idx) => {
console.log(` Message ${idx + 1}: ${msg.messageId}`);
});
const emailHeaders = [
`To: ${to}`,
`Subject: ${subject}`,
`In-Reply-To: ${inReplyToHeader}`,
`References: ${referencesHeader}`,
`Content-Type: text/html; charset=\"UTF-8\"`,
`MIME-Version: 1.0`,
''
].join('\r\n');
// Ensure proper header/body separation
const fullEmail = emailHeaders + '\r\n' + htmlBody;
// Log the complete email for debugging
console.log('Complete raw email being sent:');
console.log('=============================');
console.log(fullEmail);
console.log('=============================');
const raw = utf8ToBase64(fullEmail)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const response = await fetch(`https://gmail.googleapis.com/gmail/v1/users/me/drafts`, {
method: 'POST',
headers,
body: JSON.stringify({
message: {
threadId,
raw
}
})
});
if (!response.ok) {
const error = await response.text();
console.error('Failed to create reply draft:', error);
throw new Error('Failed to create reply draft: ' + error);
}
const result = await response.json();
console.log('Draft created successfully. Response:', {
draftId: result.id,
messageId: result.message?.id,
threadId: result.message?.threadId,
labelIds: result.message?.labelIds
});
// Verify the draft was created in the correct thread
if (result.message?.threadId !== threadId) {
console.error('WARNING: Draft was created in a different thread!', {
expected: threadId,
actual: result.message?.threadId
});
}
return result;
} else {
// Create a new thread
console.log('[Draft] About to create new thread draft for subject:', subject);
const emailHeaders = [
`To: ${to}`,
`Subject: ${subject}`,
`Content-Type: text/html; charset=\"UTF-8\"`,
`MIME-Version: 1.0`,
''
].join('\r\n');
const raw = utf8ToBase64(emailHeaders + '\r\n' + htmlBody)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const response = await fetch(`https://gmail.googleapis.com/gmail/v1/users/me/drafts`, {
method: 'POST',
headers,
body: JSON.stringify({
message: {
raw
}
})
});
if (!response.ok) {
const error = await response.text();
console.error('Failed to create new draft:', error);
throw new Error('Failed to create new draft: ' + error);
}
return await response.json();
}
}
// Helper to get OAuth token from background script
function getOAuthTokenFromBackground() {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({action: 'getOAuthToken'}, function(response) {
if (chrome.runtime.lastError || !response || response.error || !response.token) {
const errMsg = (response && response.error) ? response.error : (chrome.runtime.lastError ? chrome.runtime.lastError.message : 'No token received');
reject(errMsg);
} else {
resolve(response.token);
}
});
});
}
// Helper to extract the recipient for the draft
function extractReplyRecipient(messages, userEmail) {
console.log('Extracting reply recipient from messages:', messages.length);
console.log('User email (to exclude):', userEmail);
// Helper to parse name and email from various formats
const parseEmailAddress = (emailStr) => {
if (!emailStr) return { name: null, email: null };
// Match "Name <email@example.com>" format
const match = emailStr.match(/^([^<]+)<([^>]+)>$/);
if (match) {
return {
name: match[1].trim(),
email: match[2].trim().toLowerCase()
};
}
// If it's just an email address
if (emailStr.includes('@')) {
return {
name: null,
email: emailStr.trim().toLowerCase()
};
}
return { name: null, email: emailStr.toLowerCase() };
};
// Parse user's email to compare properly
const userEmailParsed = parseEmailAddress(userEmail);
const userEmailAddress = userEmailParsed.email || userEmail?.toLowerCase();
console.log('User email address to exclude:', userEmailAddress);
// Find the last message NOT from the user
// Start from the end and work backwards
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
// Parse the from header
if (msg.from) {
const parsed = parseEmailAddress(msg.from);
console.log(`Message ${i} from:`, parsed);
// Skip if it's from the user (compare email addresses)
if (parsed.email === userEmailAddress) {
console.log(`Skipping message ${i} - it's from the user`);
continue;
}
// Also check if sender field matches user email
if (msg.sender && msg.sender.toLowerCase() === userEmailAddress) {
console.log(`Skipping message ${i} - sender matches user email`);
continue;
}
// Found a message from someone else - this is our recipient
console.log('Found recipient (not the user):', parsed);
return {
name: parsed.name || msg.senderName || parsed.email?.split('@')[0] || 'there',
email: parsed.email || msg.sender || ''
};
}
}
// If all messages are from the user (shouldn't happen in a real thread),
// try to find a recipient from the "to" field of the first message
console.log('All messages appear to be from the user, checking "to" fields...');
for (const msg of messages) {
if (msg.to) {
const parsed = parseEmailAddress(msg.to);
if (parsed.email && parsed.email !== userEmailAddress) {
console.log('Found recipient in "to" field:', parsed);
return {
name: parsed.name || parsed.email.split('@')[0] || 'there',
email: parsed.email
};
}
}
}
// Last resort - warn and return a safe default
console.error('WARNING: Could not find a valid recipient who is not the user!');
return {
name: 'there',
email: ''
};
}
// Helper to get the user's email address from the Gmail page
function getCurrentUserEmail() {
// Try multiple methods to get the user's email
// Method 1: From the account switcher
const accountElement = document.querySelector('div[aria-label*="@"][role="button"]');
if (accountElement) {
const ariaLabel = accountElement.getAttribute('aria-label');
const emailMatch = ariaLabel.match(/[\w.+-]+@[\w.-]+\.\w+/);
if (emailMatch) {
console.log('Found user email from account switcher:', emailMatch[0]);
return emailMatch[0];
}
}
// Method 2: From the profile image
const profileImg = document.querySelector('img[alt*="@"]');
if (profileImg) {
const alt = profileImg.getAttribute('alt');
const emailMatch = alt.match(/[\w.+-]+@[\w.-]+\.\w+/);
if (emailMatch) {
console.log('Found user email from profile image:', emailMatch[0]);
return emailMatch[0];
}
}
// Method 3: From any signed-in indicator
const signedInElement = document.querySelector('[data-email]');
if (signedInElement) {
const email = signedInElement.getAttribute('data-email');
if (email) {
console.log('Found user email from data attribute:', email);
return email;
}
}
// Method 4: From the page title or other elements
const titleMatch = document.title.match(/[\w.+-]+@[\w.-]+\.\w+/);
if (titleMatch) {
console.log('Found user email from page title:', titleMatch[0]);
return titleMatch[0];
}
console.warn('Could not determine user email from page');
return null;
}
// Helper to fetch the real Message-ID header from Gmail API
async function fetchRealMessageId(token, messageId) {
const response = await fetch(`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}`, {
headers: {
'Authorization': 'Bearer ' + token
}
});
const messageData = await response.json();
const headers = messageData.payload && messageData.payload.headers ? messageData.payload.headers : [];
const realMessageIdHeader = headers.find(h => h.name.toLowerCase() === 'message-id');
return realMessageIdHeader ? realMessageIdHeader.value : null;
}
// Helper to wait for thread ID to change after clicking a row
async function waitForThreadIdChange(previousThreadId, timeout = 5000) {
return new Promise((resolve, reject) => {
const start = Date.now();
function check() {
const threadEl = document.querySelector('[data-legacy-thread-id], [data-thread-id]');
const newThreadId = threadEl?.getAttribute('data-legacy-thread-id') || threadEl?.getAttribute('data-thread-id');
if (newThreadId && newThreadId !== previousThreadId) {
resolve(newThreadId);
} else if (Date.now() - start > timeout) {
reject(new Error('Timeout waiting for thread ID to change'));
} else {
setTimeout(check, 100);
}
}
check();
});
}
// Helper: Fetch all threads in the inbox using Gmail API
async function fetchInboxThreads(token, maxResults = 100) {
console.log('Fetching threads from Gmail API...');
const threads = [];
let nextPageToken = null;
// First try to get threads without label filter (broader search)
try {
do {
const url = new URL('https://gmail.googleapis.com/gmail/v1/users/me/threads');
// Remove label filter to get more threads
// url.searchParams.set('labelIds', 'INBOX');
url.searchParams.set('maxResults', Math.min(maxResults, 100)); // API limit is 100
url.searchParams.set('q', 'is:inbox OR is:sent'); // Get both inbox and sent
if (nextPageToken) url.searchParams.set('pageToken', nextPageToken);
const response = await fetch(url.toString(), {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
if (data.threads) {
threads.push(...data.threads);
console.log(`Fetched ${data.threads.length} threads, total so far: ${threads.length}`);
}
nextPageToken = data.nextPageToken;
// Limit total threads to maxResults
if (threads.length >= maxResults) break;
} while (nextPageToken);
} catch (error) {
console.error('Error fetching threads:', error);
}
console.log(`Total threads fetched: ${threads.length}`);
// Fetch metadata for each thread (subject, sender, snippet)
const threadDetails = await Promise.all(threads.slice(0, maxResults).map(async t => {
try {
const threadResp = await fetch(`https://gmail.googleapis.com/gmail/v1/users/me/threads/${t.id}?format=metadata`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const threadData = await threadResp.json();
// Get first message headers
const msg = threadData.messages && threadData.messages[0];
if (!msg) return null;
const headers = (msg.payload && msg.payload.headers) || [];
const subject = headers.find(h => h.name.toLowerCase() === 'subject')?.value || '';
const from = headers.find(h => h.name.toLowerCase() === 'from')?.value || '';
const to = headers.find(h => h.name.toLowerCase() === 'to')?.value || '';
const date = headers.find(h => h.name.toLowerCase() === 'date')?.value || '';
const snippet = msg?.snippet || '';
return {
id: t.id,
subject,
from,
to,
date,
snippet,
messageCount: threadData.messages?.length || 0
};
} catch (error) {
console.error(`Error fetching thread ${t.id}:`, error);
return null;
}
}));
// Filter out any null results
const validThreads = threadDetails.filter(t => t !== null);
console.log(`Successfully fetched details for ${validThreads.length} threads`);
return validThreads;
}
// Helper: Get selected thread info from UI (subject, sender)
function getSelectedThreadInfo() {
const selectedCheckboxes = document.querySelectorAll('div[role="checkbox"][aria-checked="true"]');
return Array.from(selectedCheckboxes).map(cb => {
const row = cb.closest('tr');
if (!row) return null;
// Try to extract subject and sender from the row
const subjectEl = row.querySelector('span.bog');
const senderEl = row.querySelector('span.yX.xY span.zF, span.yX.xY span.yP');
const subject = subjectEl ? subjectEl.textContent.trim() : '';
const sender = senderEl ? senderEl.textContent.trim() : '';
return { subject, sender, rowId: row.id };
}).filter(info => info && (info.subject || info.sender));
}
// Enhanced thread matching with fuzzy matching and fallbacks
function normalizeText(text) {
return text.toLowerCase()
.replace(/[^\w\s]/g, '') // Remove special characters
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
}
function calculateSimilarity(str1, str2) {
const s1 = normalizeText(str1);
const s2 = normalizeText(str2);
// Exact match
if (s1 === s2) return 1;
// One contains the other
if (s1.includes(s2) || s2.includes(s1)) return 0.9;
// Calculate word overlap
const words1 = new Set(s1.split(' '));
const words2 = new Set(s2.split(' '));
const intersection = new Set([...words1].filter(x => words2.has(x)));
const union = new Set([...words1, ...words2]);
return intersection.size / union.size;
}
// Enhanced thread matching
async function matchThreadToApi(uiThread, apiThreads) {
console.log('=== THREAD MATCHING DEBUG ===');
console.log('UI Thread:', {
subject: uiThread.subject,
sender: uiThread.sender
});
console.log('Total API threads to search:', apiThreads.length);
// Log first few API threads for comparison
console.log('Sample API threads:');
apiThreads.slice(0, 3).forEach((t, i) => {
console.log(`API Thread ${i + 1}:`, {
subject: t.subject,
from: t.from,
id: t.id
});
});
// Try exact match first
const exactMatch = apiThreads.find(apiThread => {
const subjectMatch = normalizeText(apiThread.subject) === normalizeText(uiThread.subject);
const senderMatch = normalizeText(apiThread.from).includes(normalizeText(uiThread.sender));
if (subjectMatch && senderMatch) {
console.log('Exact match found!', {
apiSubject: apiThread.subject,
apiFrom: apiThread.from,
uiSubject: uiThread.subject,
uiSender: uiThread.sender
});
return true;
}
return false;
});
if (exactMatch) {
console.log('Found exact match:', exactMatch.id);
return exactMatch;
}
// Try fuzzy matching with detailed logging
console.log('No exact match, trying fuzzy matching...');
const matches = apiThreads.map(apiThread => {
const subjectScore = calculateSimilarity(apiThread.subject, uiThread.subject);
const senderScore = calculateSimilarity(apiThread.from, uiThread.sender);
const combinedScore = subjectScore * 0.7 + senderScore * 0.3;
// Log high-scoring candidates
if (combinedScore > 0.4) {
console.log('Potential match:', {
apiSubject: apiThread.subject,
apiFrom: apiThread.from,
subjectScore,
senderScore,
combinedScore,
id: apiThread.id
});
}
return {
thread: apiThread,
subjectScore,
senderScore,
score: combinedScore
};
}).filter(match => match.score > 0.5); // Lowered threshold from 0.6
if (matches.length > 0) {
// Sort by score and take the best match
matches.sort((a, b) => b.score - a.score);
console.log('Found fuzzy match:', matches[0].thread.id, 'score:', matches[0].score);
return matches[0].thread;
}
// Try subject-only matching (sometimes sender format differs significantly)
console.log('No fuzzy match, trying subject-only matching...');
const subjectOnlyMatch = apiThreads.find(apiThread => {
const score = calculateSimilarity(apiThread.subject, uiThread.subject);
if (score > 0.8) {
console.log('Subject-only match found:', {
apiSubject: apiThread.subject,
uiSubject: uiThread.subject,
score,
id: apiThread.id
});
return true;
}
return false;
});
if (subjectOnlyMatch) {
console.log('Found subject-only match:', subjectOnlyMatch.id);
return subjectOnlyMatch;
}
// Try partial subject matching (for Re:, Fwd:, etc.)
console.log('No subject match, trying partial subject matching...');
const cleanSubject = uiThread.subject.replace(/^(Re:|Fwd:|Fw:)\s*/gi, '').trim();
const partialMatch = apiThreads.find(apiThread => {
const apiCleanSubject = apiThread.subject.replace(/^(Re:|Fwd:|Fw:)\s*/gi, '').trim();
return normalizeText(apiCleanSubject).includes(normalizeText(cleanSubject)) ||
normalizeText(cleanSubject).includes(normalizeText(apiCleanSubject));
});
if (partialMatch) {
console.log('Found partial subject match:', partialMatch.id);
return partialMatch;
}
// Fallback: try to match by date if available
if (uiThread.timestamp) {
const dateMatch = apiThreads.find(apiThread => {
const apiDate = new Date(apiThread.date);
const uiDate = new Date(uiThread.timestamp);
return Math.abs(apiDate - uiDate) < 24 * 60 * 60 * 1000; // Within 24 hours
});
if (dateMatch) {
console.log('Found date-based match:', dateMatch.id);
return dateMatch;
}
}
console.warn('=== NO MATCH FOUND ===');
console.warn('Failed to match UI thread to any API thread');
console.warn('Tried: exact match, fuzzy match, subject-only, partial subject, date-based');
return null;
}
// Progress UI management
let progressUI = null;
function createProgressUI() {
if (progressUI) return;
progressUI = document.createElement('div');
progressUI.className = 'gpt-autodraft-progress';
progressUI.style.position = 'fixed';
progressUI.style.bottom = '24px';
progressUI.style.left = '24px';
progressUI.style.right = '';
progressUI.style.background = COLORS.white;
progressUI.style.border = `2px solid ${COLORS.green}`;
progressUI.style.padding = '12px';
progressUI.style.borderRadius = '8px';
progressUI.style.boxShadow = '0 2px 12px rgba(0,0,0,0.15)';
progressUI.style.zIndex = '99999';
progressUI.style.display = 'none';
progressUI.style.minWidth = '200px';
progressUI.style.maxWidth = '400px';
const status = document.createElement('div');
status.className = 'progress-status';
status.style.marginBottom = '8px';
status.style.color = COLORS.green;
status.style.fontWeight = 'bold';
const message = document.createElement('div');
message.className = 'progress-message';
message.style.color = '#666';
message.style.fontSize = '14px';
const closeBtn = document.createElement('button');
closeBtn.textContent = '×';
closeBtn.style.position = 'absolute';
closeBtn.style.top = '4px';
closeBtn.style.right = '4px';
closeBtn.style.background = 'none';
closeBtn.style.border = 'none';
closeBtn.style.fontSize = '20px';
closeBtn.style.cursor = 'pointer';
closeBtn.style.color = '#666';
closeBtn.onclick = () => {
progressUI.style.display = 'none';
};
progressUI.appendChild(closeBtn);
progressUI.appendChild(status);
progressUI.appendChild(message);
document.body.appendChild(progressUI);
}
function updateProgressUI(status, message, isError = false) {
if (!progressUI) createProgressUI();
const statusEl = progressUI.querySelector('.progress-status');
const messageEl = progressUI.querySelector('.progress-message');
statusEl.textContent = status;
messageEl.textContent = message;
if (isError) {
statusEl.style.color = '#cc0000';
progressUI.style.borderColor = '#cc0000';
} else {
statusEl.style.color = COLORS.green;
progressUI.style.borderColor = COLORS.green;
}
progressUI.style.display = 'block';
// Auto-hide success messages after 3 seconds
if (!isError && status.includes('success')) {
setTimeout(() => {
progressUI.style.display = 'none';
}, 3000);
}
}
// Update the message listener to handle progress updates
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'updateProgress') {
updateProgressUI(
message.status,
message.message || '',
message.error || false
);
}
if (message.action === 'toggleAutoDraft') {
// Find and click the reply button
const replyButton = document.querySelector('div[role="button"][gh="cm"]');
if (replyButton) {
replyButton.click();
// Wait for reply box to appear and inject UI
setTimeout(() => {
const replyBox = safeFindReplyBox();
if (replyBox) {
// Get thread messages for context
const threadMessages = Array.from(document.querySelectorAll('div[role="listitem"]'))
.map(item => item.textContent)
.join('\n');
injectUI(replyBox, '', threadMessages);
}
}, 500);
}
} else if (message.action === 'toggleUI') {
toggleUIVisibility();
}
});
// Update the batch draft function to show progress
async function generateDraftsForThreadsHybrid() {
try {
updateProgressUI('Starting', 'Preparing to generate drafts...');
const token = await getOAuthTokenFromBackground();
if (!token) throw new Error('Failed to get OAuth token');
// Get selected thread IDs using enhanced extractor if available
let selectedThreadIds;
if (window.threadExtractor) {
// Use enhanced extractor with full info
const threadInfo = await window.threadExtractor.extractThreadId('selected', {
returnFullInfo: true,
allowClickExtraction: false // Disable for batch to avoid UI disruption
});
if (!threadInfo || !threadInfo.length) {
updateProgressUI('Error', 'Please select at least one email thread.', true);
return;
}
// Extract just the thread IDs
selectedThreadIds = threadInfo.map(info => info.threadId);
// Log detailed extraction info
console.log('Enhanced extraction results:');
threadInfo.forEach((info, index) => {
console.log(`Thread ${index + 1}: ${info.threadId} (${info.extractionMethod}) - "${info.subject}" from ${info.sender}`);
});
} else {
// Fallback to regular extraction
selectedThreadIds = getSelectedThreadIds();
if (!selectedThreadIds.length) {
updateProgressUI('Error', 'Please select at least one email thread.', true);
return;
}
}
updateProgressUI('Processing', `Found ${selectedThreadIds.length} selected threads`);
// Remove duplicates
const uniqueThreadIds = [...new Set(selectedThreadIds)];
console.log('Unique thread IDs to process:', uniqueThreadIds);
let successCount = 0;
let errorCount = 0;
for (let i = 0; i < uniqueThreadIds.length; i++) {
const threadId = uniqueThreadIds[i];
try {
updateProgressUI('Processing', `Generating draft ${i + 1}/${uniqueThreadIds.length}...`);
// Get thread details directly using thread ID
const threadDetails = await getThreadDetails(token, threadId);
if (!threadDetails?.messages?.length) {
console.warn('No messages found in thread:', threadId);
errorCount++;
continue;
}
const lastMessage = threadDetails.messages[threadDetails.messages.length - 1];
const userEmail = getCurrentUserEmail() || lastMessage.from;
const recipient = extractReplyRecipient(threadDetails.messages, userEmail);
// Generate draft with retries
let draft = null;
let retries = 3;
while (retries > 0 && !draft) {
try {
draft = await requestDraft(threadDetails.messages);
} catch (error) {
console.warn(`Draft generation failed, ${retries - 1} retries left:`, error);
retries--;
if (retries > 0) await new Promise(r => setTimeout(r, 1000));
}
}
if (!draft) {
console.error('Failed to generate draft after retries for thread:', threadId);
errorCount++;
continue;
}
const formattedDraft = formatEmailReplySmart(
draft,
recipient.name || 'there',
'Curtis'
);
// Create Gmail draft with retries
retries = 3;
let draftCreated = false;
while (retries > 0 && !draftCreated) {
try {
// Ensure subject has proper "Re: " prefix without duplication
let replySubject = lastMessage.subject || 'No Subject';
if (!replySubject.toLowerCase().startsWith('re:')) {
replySubject = `Re: ${replySubject}`;
}
await createGmailDraft(
token,
recipient.email,
replySubject,
formattedDraft,
threadId,
lastMessage.messageId
);
successCount++;
draftCreated = true;
console.log(`Draft created successfully for thread ${threadId}`);
} catch (error) {
console.warn(`Draft creation failed, ${retries - 1} retries left:`, error);
retries--;
if (retries > 0) await new Promise(r => setTimeout(r, 1000));
}
}
if (!draftCreated) {
errorCount++;
}
} catch (error) {
console.error('Failed to process thread:', threadId, error);
errorCount++;
}
// Small delay between threads to avoid rate limiting
if (i < uniqueThreadIds.length - 1) {
await new Promise(r => setTimeout(r, 500));
}
}
updateProgressUI(
'Complete',
`Generated ${successCount} drafts successfully${errorCount > 0 ? `, ${errorCount} failed` : ''}`
);
} catch (error) {
console.error('Batch draft generation failed:', error);
updateProgressUI('Error', error.message, true);
throw error;
}
}
// Helper to extract first name from a full name
function getFirstName(fullName) {
if (!fullName || fullName === 'there') return fullName;
// Remove any email addresses if present
const nameOnly = fullName.split('<')[0].trim();
// Handle common name formats
const parts = nameOnly.split(/\s+/);
// If it's a single word, return it
if (parts.length === 1) return parts[0];
// Check if first part is a title (Mr., Mrs., Dr., etc.)
const titles = ['mr', 'mrs', 'ms', 'miss', 'dr', 'prof', 'professor'];
if (titles.includes(parts[0].toLowerCase().replace('.', ''))) {
// Return the next part as first name
return parts[1] || parts[0];
}
// Otherwise, return the first part as the first name
return parts[0];
}
// Format the reply as a proper email
function hasGreeting(draft) {
// Check for common greetings
if (/^(hi|hello|hey|dear|good\s*(morning|afternoon|evening))[^\n,]*[,\n]/i.test(draft.trim())) {
return true;
}
// Check if it starts with a name followed by comma or colon (e.g., "John," or "John:")
// This catches cases like "Aundre," or "Maria:" as valid greetings
if (/^[A-Z][a-zA-Z\s\-']+[,:]\s*$/m.test(draft.trim().split('\n')[0])) {
return true;
}
// Check if it starts with a title and name (e.g., "Mr. Smith," or "Dr. Johnson:")
if (/^(Mr\.|Mrs\.|Ms\.|Dr\.|Prof\.)\s+[A-Za-z\s\-']+[,:]/i.test(draft.trim())) {
return true;
}
return false;
}
function hasSignature(draft) {
return /(best|thanks|thank you|sincerely|regards|cheers|cordially|warmly|respectfully|yours)[^\n]*[\n\r]+[\w\s]+$/i.test(draft.trim());
}
function formatEmailReplySmart(draft, recipientName = 'there', senderName = 'Curtis') {
let result = draft.trim();
// First, clean up any GPT analysis that might have leaked through
result = cleanGptAnalysis(result);
// Use only first name for greeting to be more natural
const greetingName = getFirstName(recipientName);
// Log what we're checking
console.log('Recipient full name:', recipientName);
console.log('Using first name for greeting:', greetingName);
console.log('Checking if draft has greeting. First line:', result.split('\n')[0]);
console.log('Has greeting?', hasGreeting(result));
// Add greeting if needed
if (!hasGreeting(result)) {
result = `Hi ${greetingName},\n\n${result}`;
}
// IMPORTANT: Only remove truly redundant names, not contextual mentions
// Examples of what we REMOVE:
// "Hi Maria,\n\nMaria, I wanted to..." → "Hi Maria,\n\nI wanted to..."
// Examples of what we PRESERVE:
// "Hi Kirk,\n\nHere with Maria, just jumping in..." → No change (Maria is contextual)
// "Hi John,\n\nI spoke with Maria about..." → No change (Maria is contextual)
// "Hi Maria,\n\nI think Maria from accounting..." → No change (different Maria)
// Check if the name appears redundantly right after the greeting
// This handles cases like "Hi Maria,\n\nMaria, I wanted to..."
// But preserves contextual uses like "Here with Maria" or "I spoke to Maria"
const lines = result.split('\n');
const greetingLineIndex = lines.findIndex(line => hasGreeting(line.trim()));
if (greetingLineIndex !== -1 && greetingName !== 'there') {
// Find the first content line after the greeting
let firstContentLineIndex = -1;
for (let i = greetingLineIndex + 1; i < lines.length; i++) {
if (lines[i].trim()) {
firstContentLineIndex = i;
break;
}
}
if (firstContentLineIndex !== -1) {
const firstContentLine = lines[firstContentLineIndex];
// Only remove if:
// 1. The name appears at the very start of the line
// 2. It's followed by a comma (indicating direct address)
// 3. It's the same name as in the greeting (avoiding "Hi John, Maria and I...")
const redundantNamePattern = new RegExp(`^\\s*${greetingName}\\s*,`, 'i');
if (redundantNamePattern.test(firstContentLine)) {
console.log('Found redundant direct address after greeting, removing it');
console.log('Before:', firstContentLine);
// Remove only the redundant name and comma at the beginning
lines[firstContentLineIndex] = firstContentLine
.replace(redundantNamePattern, '')
.trim();
console.log('After:', lines[firstContentLineIndex]);
result = lines.join('\n');
} else {
console.log('Name found in content but appears to be contextual, preserving it');
}
}
}
// Add signature if needed
if (!hasSignature(result)) {
result = `${result}\n\nBest,\n${senderName}`;
}
return result;
}
// Function to clean GPT analysis from the response
function cleanGptAnalysis(text) {
// Remove numbered analysis steps and strategy explanations
const cleanPatterns = [
/^\d+\.\s*(Analysis|Strategy|Follow-up|Search|Outline|Draft|Review).*$/gmi,
/^##\s*(Analysis|Strategy|Process|Search|Outline).*$/gmi,
/^\*\*(Analysis|Strategy|Follow-up|Search|Outline|Draft|Review).*$/gmi,
/^(Analysis of thread|Follow-up strategy|Search terms|Outline|Review):.*$/gmi,
/^(1\.|2\.|3\.|4\.|5\.)\s*(Analysis|Strategy|Follow-up).*$/gmi
];
let cleaned = text;
// Apply each cleaning pattern
for (const pattern of cleanPatterns) {
cleaned = cleaned.replace(pattern, '');
}
// Remove any standalone section headers
cleaned = cleaned.replace(/^(Analysis:|Strategy:|Process:|Outline:|Review:).*$/gmi, '');
// Remove multiple consecutive newlines
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
// Remove any remaining numbered list items at the start that look like analysis
const lines = cleaned.split('\n');
const filteredLines = [];
let foundActualContent = false;
for (const line of lines) {
const trimmedLine = line.trim();
// Skip analysis-looking lines at the beginning
if (!foundActualContent && (
/^\d+\.\s*(analysis|strategy|follow|search|outline|draft|review)/i.test(trimmedLine) ||
/^(analysis|strategy|follow-up|search|outline|draft|review):/i.test(trimmedLine) ||
trimmedLine.startsWith('##') ||
trimmedLine.startsWith('**Analysis') ||
trimmedLine.startsWith('**Strategy')
)) {
continue;
}
// Once we find actual email content, include everything
if (trimmedLine && !foundActualContent) {
foundActualContent = true;
}
filteredLines.push(line);
}
cleaned = filteredLines.join('\n').trim();
console.log('Cleaned GPT analysis. Original length:', text.length, 'Cleaned length:', cleaned.length);
return cleaned;
}
// Extract the recipient's name from the last message in the thread
function extractRecipientName() {
// Try to find the last sender's name in the visible thread
const senderElements = Array.from(document.querySelectorAll('span[email], span[email][name], span[email][data-hovercard-id]'));
if (senderElements.length > 0) {
// Use the last sender before the reply box
const lastSender = senderElements[senderElements.length - 1];
// Try to get the name attribute, fallback to textContent
return lastSender.getAttribute('name') || lastSender.textContent.trim() || 'there';
}
// Fallback: try to find the name in the last message header
const lastHeader = document.querySelector('div[role="listitem"] span[email]');
if (lastHeader) {
return lastHeader.getAttribute('name') || lastHeader.textContent.trim() || 'there';
}
return 'there';
}
// Add this utility function near the top or with other helpers
function insertDraftWithRetry(draft, maxAttempts = 5, delay = 300) {
let attempts = 0;
function tryInsert() {
const replyBox = safeFindReplyBox();
if (replyBox) {
replyBox.focus();
// Try execCommand first
document.execCommand('selectAll', false, null);
document.execCommand('insertText', false, draft);
// Fallback to innerHTML if execCommand fails
if (!replyBox.innerText.includes(draft.split('\n')[0])) {
replyBox.innerHTML = draft.replace(/\n/g, '<br>');
}
// Dispatch input event
replyBox.dispatchEvent(new Event('input', { bubbles: true }));
console.log('Draft inserted into reply box');
return;
}
attempts++;
setTimeout(tryInsert, delay);
}
tryInsert();
}
// Add the robust one-off autodraft function
async function oneOffAutoDraftForCurrentThread(contextPrompt = null) {
try {
updateProgressUI('Initializing', 'Getting authentication token...');
const token = await getOAuthTokenFromBackground();
if (!token) throw new Error('Failed to get OAuth token');
updateProgressUI('Analyzing', 'Extracting thread ID...');
// Use enhanced extraction with retries
let threadId = null;
let attempts = 0;
const maxAttempts = 3;
while (!threadId && attempts < maxAttempts) {
attempts++;
try {
// Try to get thread ID with async extraction (enhanced extractor)
if (window.threadExtractor) {
// Use enhanced extractor if available
threadId = await window.threadExtractor.extractThreadId('current', {
forceRefresh: attempts > 1, // Force refresh on retry
token: token // Provide token for API fallback
});
} else {
// Fallback to sync extraction
threadId = extractThreadId('current');
}
} catch (error) {
console.warn(`Attempt ${attempts} failed:`, error);
if (attempts < maxAttempts) {
updateProgressUI('Analyzing', `Retrying thread extraction (attempt ${attempts + 1})...`);
await new Promise(r => setTimeout(r, 1000));
}
}
}
if (!threadId) {
updateProgressUI('Error', 'Could not determine current thread ID', true);
throw new Error('Could not determine current thread ID. Make sure you are viewing an email thread.');
}
console.log('Extracted thread ID:', threadId);
updateProgressUI('Processing', 'Getting thread details...');
// Get thread details directly using the thread ID
const threadDetails = await getThreadDetails(token, threadId);
if (!threadDetails || !threadDetails.messages.length) {
updateProgressUI('Error', 'No messages found in thread', true);
throw new Error('No messages found in thread.');
}
// Use last message for context
const lastMessage = threadDetails.messages[threadDetails.messages.length - 1];
const userEmail = getCurrentUserEmail() || lastMessage.from;
const recipient = extractReplyRecipient(threadDetails.messages, userEmail);
updateProgressUI('Generating', 'Creating draft with AI...');
// Generate draft content with retry logic
let draft = null;
let retries = 3;
while (retries > 0 && !draft) {
try {
draft = await requestDraft(threadDetails.messages, contextPrompt);
} catch (error) {
console.warn(`Draft generation failed, ${retries - 1} retries left:`, error);
retries--;
if (retries > 0) {
updateProgressUI('Generating', `Retrying draft generation (${4 - retries}/3)...`);
await new Promise(r => setTimeout(r, 2000));
}
}
}
if (!draft) {
updateProgressUI('Error', 'Failed to generate draft after retries', true);
throw new Error('Failed to generate draft content after multiple attempts.');
}
const formattedDraft = formatEmailReplySmart(
draft,
recipient.name || 'there',
'Curtis'
);
// Always fill the reply box for the user
insertDraftWithRetry(formattedDraft);
updateProgressUI('Saving', 'Saving draft to Gmail...');
// Create the draft in Gmail with retries
retries = 3;
let draftCreated = false;
while (retries > 0 && !draftCreated) {
try {
// Ensure subject has proper "Re: " prefix without duplication
let replySubject = lastMessage.subject || 'No Subject';
if (!replySubject.toLowerCase().startsWith('re:')) {
replySubject = `Re: ${replySubject}`;
}
const result = await createGmailDraft(
token,
recipient.email,
replySubject,
formattedDraft,
threadId,
lastMessage.messageId
);
draftCreated = true;
updateProgressUI('Success', 'Draft created successfully!');
console.log('One-off draft created successfully:', result);
return result;
} catch (error) {
console.warn(`Draft creation failed, ${retries - 1} retries left:`, error);
retries--;
if (retries > 0) {
updateProgressUI('Saving', `Retrying draft save (${4 - retries}/3)...`);
await new Promise(r => setTimeout(r, 1000));
}
}
}
if (!draftCreated) {
updateProgressUI('Error', 'Failed to save draft to Gmail', true);
throw new Error('Failed to save draft to Gmail after multiple attempts.');
}
} catch (error) {
console.error('Failed to create one-off autodraft:', error);
updateProgressUI('Error', error.message, true);
throw error;
}
}
// Robust observer for Gmail UI changes
function main() {
console.log('Starting robust Gmail observer');
let lastButtonInjected = false;
const checkAndInject = () => {
if (isInboxPage()) {
if (!document.querySelector('.gpt-batch-draft-button')) {
addBatchDraftButton();
lastButtonInjected = true;
}
} else {
// Remove button if not on inbox
const btn = document.querySelector('.gpt-batch-draft-button');
if (btn) btn.remove();
lastButtonInjected = false;
}
};
// Initial check
checkAndInject();
// Observe DOM changes
const observer = new MutationObserver(() => {
checkAndInject();
});
observer.observe(document.body, { childList: true, subtree: true });
// Also poll every 2 seconds in case observer misses something
setInterval(checkAndInject, 2000);
// Listen for thread ID detection events from enhanced extractor
document.addEventListener('threadIdDetected', (event) => {
console.log('Thread ID detected by enhanced extractor:', event.detail.threadId);
// Update any UI elements that depend on thread ID
const draftButton = document.querySelector('.gpt-autodraft-ui button');
if (draftButton) {
draftButton.disabled = false;
}
// Store the thread ID for quick access
window.currentThreadId = event.detail.threadId;
});
// Log enhanced extractor status
setTimeout(() => {
if (window.threadExtractor) {
logWithStyle('Enhanced thread extractor is active', 'success');
logWithStyle('7+ extraction methods available', 'info');
} else {
logWithStyle('Enhanced thread extractor not loaded, using fallback methods', 'warning');
}
}, 2000);
}
main();
// Compatibility functions for thread extraction
// These provide backward compatibility when enhanced extractor is not available
function extractThreadId(context = 'current') {
console.log(`=== EXTRACTING THREAD ID (${context}) ===`);
if (context === 'current') {
// Single thread view - multiple methods
// Method 1: From URL hash (most reliable for single thread)
const urlMatch = window.location.hash.match(/[#/]([a-f0-9]{16})$/i);
if (urlMatch) {
console.log('Found thread ID in URL:', urlMatch[1]);
return urlMatch[1];
}
// Method 2: From conversation view header
const headerElement = document.querySelector('h2[data-legacy-thread-id], h2[data-thread-perm-id]');
if (headerElement) {
const threadId = headerElement.getAttribute('data-legacy-thread-id') ||
headerElement.getAttribute('data-thread-perm-id');
if (threadId) {
console.log('Found thread ID in header:', threadId);
return threadId;
}
}
// Method 3: From any element with thread ID attributes
const threadElement = document.querySelector('[data-thread-perm-id], [data-legacy-thread-id], [data-thread-id]');
if (threadElement) {
const threadId = threadElement.getAttribute('data-thread-perm-id') ||
threadElement.getAttribute('data-legacy-thread-id') ||
threadElement.getAttribute('data-thread-id');
if (threadId && threadId.length === 16) {
console.log('Found thread ID in DOM:', threadId);
return threadId;
}
}
// Method 4: From message list items
const messageItem = document.querySelector('div[role="listitem"][data-legacy-message-id]');
if (messageItem) {
const threadContainer = messageItem.closest('[data-legacy-thread-id]');
if (threadContainer) {
const threadId = threadContainer.getAttribute('data-legacy-thread-id');
if (threadId) {
console.log('Found thread ID from message container:', threadId);
return threadId;
}
}
}
console.warn('Could not extract current thread ID');
return null;
}
// For batch selection from inbox
if (context === 'selected') {
const selectedCheckboxes = document.querySelectorAll('div[role="checkbox"][aria-checked="true"]');
const threadIds = Array.from(selectedCheckboxes).map(checkbox => {
const row = checkbox.closest('tr');
if (!row) return null;
// Method 1: data-legacy-thread-id attribute
let threadId = row.getAttribute('data-legacy-thread-id');
if (threadId) return threadId;
// Method 2: data-thread-id attribute
threadId = row.getAttribute('data-thread-id');
if (threadId) return threadId;
// Method 3: Check within row for thread ID
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');
if (threadId) return threadId;
}
// Method 4: Check row ID
if (row.id && row.id.includes(':')) {
const parts = row.id.split(':');
const possibleThreadId = parts[parts.length - 1];
if (possibleThreadId && possibleThreadId.length === 16) {
return possibleThreadId;
}
}
return null;
}).filter(id => id !== null);
console.log(`Found ${threadIds.length} selected thread IDs:`, threadIds);
return threadIds;
}
return null;
}
function getSelectedThreadIds() {
return extractThreadId('selected');
}
// Helper to get OAuth token from background script
function getOAuthTokenFromBackground() {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({action: 'getOAuthToken'}, function(response) {
if (chrome.runtime.lastError || !response || response.error || !response.token) {
const errMsg = (response && response.error) ? response.error : (chrome.runtime.lastError ? chrome.runtime.lastError.message : 'No token received');
reject(errMsg);
} else {
resolve(response.token);
}
});
});
}