2301 lines
No EOL
79 KiB
JavaScript
2301 lines
No EOL
79 KiB
JavaScript
// 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);
|
||
}
|
||
});
|
||
});
|
||
} |