gpt_chrome_autodrafter_v3/content.js
2025-07-01 15:46:34 -07:00

2301 lines
No EOL
79 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);
}
});
});
}