From 791985090730c48d49c4be8ee2d1ca86e11db0bd Mon Sep 17 00:00:00 2001 From: NekoWings Date: Tue, 9 Sep 2025 10:42:04 +0800 Subject: [PATCH 1/6] feat: add Gmail Tools settings and functionality - Introduced a new EmailToolsSettings component for configuring Gmail tools. - Updated Sidebar component to include a link to Gmail Tools. - Modified router to add a route for Gmail Tools settings. - Implemented Gmail action handling in the side panel for summarizing emails. - Enhanced Chat class to support custom prompts for Gmail actions. - Added new default system prompts for summarizing, replying, and composing emails. - Updated user configuration to include settings for email tools. - Improved background functions to handle Gmail actions more effectively. --- assets/icons/copy.svg | 4 + assets/icons/done.svg | 4 + assets/icons/language.svg | 10 + assets/icons/regenerate.svg | 3 + assets/icons/reply-suggestion.svg | 5 + assets/icons/settings-email.svg | 8 + assets/icons/writing-style.svg | 5 + components/IconSelector.vue | 289 +++++++++ components/RootProvider.vue | 8 + composables/useInjectContext.ts | 2 + entrypoints/background/index.ts | 5 + .../background/services/window-manager.ts | 112 ++++ entrypoints/content/App.vue | 6 + .../GmailTools/GmailComposeCard.vue | 533 +++++++++++++++++ .../components/GmailTools/GmailReplyCard.vue | 443 ++++++++++++++ .../content/components/GmailTools/index.vue | 228 ++++++++ .../content/components/WritingTools/index.vue | 4 +- .../content/composables/useGmailDetector.ts | 232 ++++++++ .../composables/useGmailToolsRootElement.ts | 5 + .../composables/useWritingToolsRootElement.ts | 5 + entrypoints/content/index.tsx | 4 +- entrypoints/content/ui.ts | 8 +- .../content/utils/gmail/email-extractor.ts | 266 +++++++++ entrypoints/content/utils/gmail/settings.ts | 41 ++ .../utils/page-injection/gmail-tools.ts | 547 ++++++++++++++++++ .../components/EmailToolsSettings/index.vue | 374 ++++++++++++ entrypoints/settings/components/Sidebar.vue | 2 + entrypoints/settings/router.ts | 2 + entrypoints/sidepanel/App.vue | 49 ++ entrypoints/sidepanel/utils/chat/chat.ts | 6 +- utils/rpc/background-fns.ts | 31 +- utils/rpc/content-main-world-fns.ts | 2 +- utils/rpc/sidepanel-fns.ts | 1 + utils/user-config/defaults.ts | 96 +++ utils/user-config/index.ts | 16 +- 35 files changed, 3345 insertions(+), 11 deletions(-) create mode 100644 assets/icons/copy.svg create mode 100644 assets/icons/done.svg create mode 100644 assets/icons/language.svg create mode 100644 assets/icons/regenerate.svg create mode 100644 assets/icons/reply-suggestion.svg create mode 100644 assets/icons/settings-email.svg create mode 100644 assets/icons/writing-style.svg create mode 100644 components/IconSelector.vue create mode 100644 entrypoints/background/services/window-manager.ts create mode 100644 entrypoints/content/components/GmailTools/GmailComposeCard.vue create mode 100644 entrypoints/content/components/GmailTools/GmailReplyCard.vue create mode 100644 entrypoints/content/components/GmailTools/index.vue create mode 100644 entrypoints/content/composables/useGmailDetector.ts create mode 100644 entrypoints/content/composables/useGmailToolsRootElement.ts create mode 100644 entrypoints/content/composables/useWritingToolsRootElement.ts create mode 100644 entrypoints/content/utils/gmail/email-extractor.ts create mode 100644 entrypoints/content/utils/gmail/settings.ts create mode 100644 entrypoints/content/utils/page-injection/gmail-tools.ts create mode 100644 entrypoints/settings/components/EmailToolsSettings/index.vue diff --git a/assets/icons/copy.svg b/assets/icons/copy.svg new file mode 100644 index 00000000..f4650381 --- /dev/null +++ b/assets/icons/copy.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/assets/icons/done.svg b/assets/icons/done.svg new file mode 100644 index 00000000..4b295c40 --- /dev/null +++ b/assets/icons/done.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/assets/icons/language.svg b/assets/icons/language.svg new file mode 100644 index 00000000..e27f08db --- /dev/null +++ b/assets/icons/language.svg @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/assets/icons/regenerate.svg b/assets/icons/regenerate.svg new file mode 100644 index 00000000..55bb4a72 --- /dev/null +++ b/assets/icons/regenerate.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/icons/reply-suggestion.svg b/assets/icons/reply-suggestion.svg new file mode 100644 index 00000000..502c9f73 --- /dev/null +++ b/assets/icons/reply-suggestion.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/assets/icons/settings-email.svg b/assets/icons/settings-email.svg new file mode 100644 index 00000000..9800cf54 --- /dev/null +++ b/assets/icons/settings-email.svg @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/assets/icons/writing-style.svg b/assets/icons/writing-style.svg new file mode 100644 index 00000000..ccb55777 --- /dev/null +++ b/assets/icons/writing-style.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/components/IconSelector.vue b/components/IconSelector.vue new file mode 100644 index 00000000..54d7aadf --- /dev/null +++ b/components/IconSelector.vue @@ -0,0 +1,289 @@ + + + diff --git a/components/RootProvider.vue b/components/RootProvider.vue index a7fe2dd2..730b3527 100644 --- a/components/RootProvider.vue +++ b/components/RootProvider.vue @@ -7,8 +7,16 @@ import { useInjectContext } from '@/composables/useInjectContext' const props = defineProps<{ rootElement: HTMLElement + writingToolsRoot?: HTMLElement + gmailToolsRoot?: HTMLElement }>() useInjectContext('selectorScrollListenElement').provide([props.rootElement]) useInjectContext('rootElement').provide(props.rootElement) +if (props.writingToolsRoot) { + useInjectContext('writingToolsRoot').provide(props.writingToolsRoot) +} +if (props.gmailToolsRoot) { + useInjectContext('gmailToolsRoot').provide(props.gmailToolsRoot) +} diff --git a/composables/useInjectContext.ts b/composables/useInjectContext.ts index b9ca2ba3..0509850d 100644 --- a/composables/useInjectContext.ts +++ b/composables/useInjectContext.ts @@ -2,6 +2,8 @@ import { inject, provide } from 'vue' type InjectContext = { rootElement: HTMLElement + writingToolsRoot: HTMLElement | undefined + gmailToolsRoot: HTMLElement | undefined selectorScrollListenElement: HTMLElement[] } diff --git a/entrypoints/background/index.ts b/entrypoints/background/index.ts index d762c414..98d0bbe4 100644 --- a/entrypoints/background/index.ts +++ b/entrypoints/background/index.ts @@ -17,6 +17,7 @@ import { registerDeclarativeNetRequestRule } from '@/utils/web-request' import { BackgroundDatabaseManager } from './database' import { BackgroundCacheServiceManager } from './services/cache-service' import { BackgroundChatHistoryServiceManager } from './services/chat-history-service' +import { BackgroundWindowManager } from './services/window-manager' import { waitUntilSidepanelLoaded } from './utils' export default defineBackground(() => { @@ -169,6 +170,10 @@ export default defineBackground(() => { await translationCache.initialize() logger.debug('Translation cache initialized successfully') + // Initialize window manager service + await BackgroundWindowManager.initialize() + logger.debug('Window manager service initialized successfully') + // ================================ // Debug Code // ================================ diff --git a/entrypoints/background/services/window-manager.ts b/entrypoints/background/services/window-manager.ts new file mode 100644 index 00000000..ef9969dd --- /dev/null +++ b/entrypoints/background/services/window-manager.ts @@ -0,0 +1,112 @@ +import { Browser, browser } from 'wxt/browser' + +import logger from '@/utils/logger' + +/** + * Window Manager Service for tracking active window IDs + * This service maintains a cache of the current active window ID to avoid async calls before sidePanel.open() + */ +class WindowManagerService { + private currentWindowId: number | null = null + private initialized = false + + /** + * Initialize the window manager service + */ + async initialize(): Promise { + if (this.initialized) { + return + } + + try { + // Get initial current window + const currentWindow = await browser.windows.getCurrent() + this.currentWindowId = currentWindow.id || null + logger.debug('Window Manager initialized with window ID:', this.currentWindowId) + + // Listen for window focus changes + browser.windows.onFocusChanged.addListener(this.handleWindowFocusChanged.bind(this)) + + // Listen for window created/removed events + browser.windows.onCreated.addListener(this.handleWindowCreated.bind(this)) + browser.windows.onRemoved.addListener(this.handleWindowRemoved.bind(this)) + + this.initialized = true + } + catch (error) { + logger.error('Failed to initialize Window Manager:', error) + throw error + } + } + + /** + * Get the current cached window ID (synchronous) + */ + getCurrentWindowId(): number | null { + return this.currentWindowId + } + + /** + * Handle window focus change events + */ + private handleWindowFocusChanged(windowId: number): void { + if (windowId !== browser.windows.WINDOW_ID_NONE) { + this.currentWindowId = windowId + logger.debug('Active window changed to:', windowId) + } + } + + /** + * Handle window created events + */ + private handleWindowCreated(window: Browser.windows.Window): void { + if (window.focused && window.id) { + this.currentWindowId = window.id + logger.debug('New focused window created:', window.id) + } + } + + /** + * Handle window removed events + */ + private handleWindowRemoved(windowId: number): void { + if (windowId === this.currentWindowId) { + // Current window was closed, try to get a new active window + this.updateCurrentWindow() + } + } + + /** + * Update current window by querying browser (fallback method) + */ + private async updateCurrentWindow(): Promise { + try { + const currentWindow = await browser.windows.getCurrent() + this.currentWindowId = currentWindow.id || null + logger.debug('Updated current window ID:', this.currentWindowId) + } + catch (error) { + logger.warn('Failed to update current window:', error) + this.currentWindowId = null + } + } + + /** + * Cleanup listeners on service shutdown + */ + cleanup(): void { + if (browser.windows.onFocusChanged.hasListener(this.handleWindowFocusChanged)) { + browser.windows.onFocusChanged.removeListener(this.handleWindowFocusChanged) + } + if (browser.windows.onCreated.hasListener(this.handleWindowCreated)) { + browser.windows.onCreated.removeListener(this.handleWindowCreated) + } + if (browser.windows.onRemoved.hasListener(this.handleWindowRemoved)) { + browser.windows.onRemoved.removeListener(this.handleWindowRemoved) + } + this.initialized = false + logger.debug('Window Manager service cleaned up') + } +} + +export const BackgroundWindowManager = new WindowManagerService() diff --git a/entrypoints/content/App.vue b/entrypoints/content/App.vue index 0f76b027..106854c4 100644 --- a/entrypoints/content/App.vue +++ b/entrypoints/content/App.vue @@ -2,20 +2,26 @@ + + + diff --git a/entrypoints/content/components/GmailTools/GmailReplyCard.vue b/entrypoints/content/components/GmailTools/GmailReplyCard.vue new file mode 100644 index 00000000..c9bb7f79 --- /dev/null +++ b/entrypoints/content/components/GmailTools/GmailReplyCard.vue @@ -0,0 +1,443 @@ + + + diff --git a/entrypoints/content/components/GmailTools/index.vue b/entrypoints/content/components/GmailTools/index.vue new file mode 100644 index 00000000..95d53e89 --- /dev/null +++ b/entrypoints/content/components/GmailTools/index.vue @@ -0,0 +1,228 @@ + + + diff --git a/entrypoints/content/components/WritingTools/index.vue b/entrypoints/content/components/WritingTools/index.vue index 86a588bd..9e38b2d7 100644 --- a/entrypoints/content/components/WritingTools/index.vue +++ b/entrypoints/content/components/WritingTools/index.vue @@ -32,12 +32,12 @@ import { injectStyleSheetToDocument, loadContentScriptStyleSheet } from '@/utils import { isContentEditableElement, isEditorFrameworkElement, shouldExcludeEditableElement } from '@/utils/selection' import { getUserConfig } from '@/utils/user-config' -import { useRootElement } from '../../composables/useRootElement' +import { useWritingToolsRootElement } from '../../composables/useWritingToolsRootElement' import EditableEntry from './EditableEntry.vue' import { WritingToolType } from './types' const logger = useLogger() -const rootElement = useRootElement() +const rootElement = useWritingToolsRootElement() const styleSheet = shallowRef(null) const shadowRootRef = ref>() const userConfig = await getUserConfig() diff --git a/entrypoints/content/composables/useGmailDetector.ts b/entrypoints/content/composables/useGmailDetector.ts new file mode 100644 index 00000000..6a6e287b --- /dev/null +++ b/entrypoints/content/composables/useGmailDetector.ts @@ -0,0 +1,232 @@ +import { onMounted, onUnmounted, ref } from 'vue' + +import { useLogger } from '@/composables/useLogger' + +export interface EmailThread { + element: HTMLElement + id?: string +} + +export interface ComposeArea { + element: HTMLElement + type: 'reply' | 'new' +} + +export function useGmailDetector() { + const logger = useLogger() + const emailThreads = ref([]) + const composeAreas = ref([]) + const newEmailAreas = ref([]) + const emailSubjectArea = ref(null) + const currentThread = ref(null) + + let observer: MutationObserver | null = null + + function detectEmailSubjectArea(): HTMLElement | null { + return document.querySelector('[data-subject-announcement]') + } + + function detectCurrentThread(): EmailThread | null { + // Look for currently active/focused thread + // Gmail shows the active thread with role="main" + + const selectors = '.nH[role="main"]' // Most reliable - active thread container + + const element = document.querySelector(selectors) + + if (element instanceof HTMLElement) { + return { + element, + } + } + + return null + } + + function detectEmailThreads(): EmailThread[] { + const threads: EmailThread[] = [] + + // Gmail thread view selectors (these may need adjustment based on Gmail's current structure) + const threadElements = document.querySelectorAll('[data-thread-id]') + + threadElements.forEach((element, index) => { + if (element instanceof HTMLElement) { + const threadId = element.getAttribute('data-thread-id') || `thread-${index}` + + // Only add if not already tracked + if (!emailThreads.value.find((t) => t.id === threadId)) { + threads.push({ + element, + id: threadId, + }) + } + } + }) + + return threads + } + + function detectComposeAreas(): { replies: ComposeArea[], newEmails: ComposeArea[] } { + const replies: ComposeArea[] = [] + const newEmails: ComposeArea[] = [] + + // Gmail compose/reply areas + const composeElements = document.querySelectorAll('[role="dialog"]') + + composeElements.forEach((element) => { + if (element instanceof HTMLElement) { + // Check if it's a reply (contains original message) or new email + const isReply = element.querySelector('[data-original-message]') !== null + || element.textContent?.includes('wrote:') + || element.textContent?.includes('On ') // Common reply patterns + + const compose: ComposeArea = { + element, + type: isReply ? 'reply' : 'new', + } + + if (isReply) { + replies.push(compose) + } + else { + newEmails.push(compose) + } + } + }) + + // Alternative selectors for compose areas + const alternativeComposeElements = document.querySelectorAll('.nH .nN') + alternativeComposeElements.forEach((element) => { + if (element instanceof HTMLElement && element.querySelector('[contenteditable="true"]')) { + const isReply = element.closest('[data-thread-id]') !== null + + const compose: ComposeArea = { + element, + type: isReply ? 'reply' : 'new', + } + + // Avoid duplicates + const existingList = isReply ? replies : newEmails + const isDuplicate = existingList.some((c) => c.element === element) + + if (!isDuplicate) { + if (isReply) { + replies.push(compose) + } + else { + newEmails.push(compose) + } + } + } + }) + + return { replies, newEmails } + } + + function scanForGmailElements() { + try { + // Detect email threads for summary + const threads = detectEmailThreads() + emailThreads.value = threads + + // Detect current active thread + const current = detectCurrentThread() + currentThread.value = current + + // Detect compose areas for reply/polish + const { replies, newEmails } = detectComposeAreas() + composeAreas.value = replies + newEmailAreas.value = newEmails + + // Detect Current Email Subject Area + emailSubjectArea.value = detectEmailSubjectArea() + + logger.debug('Gmail elements detected:', { + threads: threads, + currentThread: current, + replies: replies, + newEmails: newEmails, + subject: emailSubjectArea.value, + }) + } + catch (error) { + logger.error('Error scanning Gmail elements:', error) + } + } + + function startObserver() { + if (observer) { + stopObserver() + } + + observer = new MutationObserver((mutations) => { + let shouldRescan = false + + mutations.forEach((mutation) => { + // Check if new elements were added that might be Gmail UI + if (mutation.type === 'childList') { + const addedNodes = Array.from(mutation.addedNodes) + const hasRelevantChanges = addedNodes.some((node) => { + if (node instanceof HTMLElement) { + return node.matches('[data-thread-id], [role="dialog"], .nH .nN') + || node.querySelector('[data-thread-id], [role="dialog"], .nH .nN') !== null + } + return false + }) + + if (hasRelevantChanges) { + shouldRescan = true + } + } + }) + + if (shouldRescan) { + // Debounce the rescan to avoid excessive calls + setTimeout(scanForGmailElements, 100) + } + }) + + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: false, + }) + + logger.debug('Gmail DOM observer started') + } + + function stopObserver() { + if (observer) { + observer.disconnect() + observer = null + logger.debug('Gmail DOM observer stopped') + } + } + + onMounted(() => { + // Initial scan + scanForGmailElements() + + // Start observing for changes + startObserver() + + // Periodic rescan for dynamic content + const intervalId = setInterval(scanForGmailElements, 5000) + + onUnmounted(() => { + clearInterval(intervalId) + stopObserver() + }) + }) + + return { + emailThreads, + composeAreas, + newEmailAreas, + emailSubjectArea, + currentThread, + scanForGmailElements, + startObserver, + stopObserver, + } +} diff --git a/entrypoints/content/composables/useGmailToolsRootElement.ts b/entrypoints/content/composables/useGmailToolsRootElement.ts new file mode 100644 index 00000000..97ff8531 --- /dev/null +++ b/entrypoints/content/composables/useGmailToolsRootElement.ts @@ -0,0 +1,5 @@ +import { useInjectContext } from '@/composables/useInjectContext' + +export function useGmailToolsRootElement() { + return useInjectContext('gmailToolsRoot').inject() +} diff --git a/entrypoints/content/composables/useWritingToolsRootElement.ts b/entrypoints/content/composables/useWritingToolsRootElement.ts new file mode 100644 index 00000000..c5666a13 --- /dev/null +++ b/entrypoints/content/composables/useWritingToolsRootElement.ts @@ -0,0 +1,5 @@ +import { useInjectContext } from '@/composables/useInjectContext' + +export function useWritingToolsRootElement() { + return useInjectContext('writingToolsRoot').inject() +} diff --git a/entrypoints/content/index.tsx b/entrypoints/content/index.tsx index d36aaf22..7c38df79 100644 --- a/entrypoints/content/index.tsx +++ b/entrypoints/content/index.tsx @@ -18,11 +18,11 @@ export default defineContentScript({ cssInjectionMode: 'manual', runAt: 'document_start', async main(ctx) { - const ui = await createShadowRootOverlay(ctx, ({ rootElement }) => { + const ui = await createShadowRootOverlay(ctx, ({ rootElement, writingToolsRoot, gmailToolsRoot }) => { rootElement.classList.add('font-inter', 'nativemind-style-boundary') return ( - + diff --git a/entrypoints/content/ui.ts b/entrypoints/content/ui.ts index cd3aeacd..d4d9e7f4 100644 --- a/entrypoints/content/ui.ts +++ b/entrypoints/content/ui.ts @@ -18,7 +18,7 @@ async function loadStyleSheet(shadowRoot: ShadowRoot) { injectStyleSheetToDocument(document, fontFaceStyleSheet) } -export async function createShadowRootOverlay(ctx: ContentScriptContext, component: Component<{ rootElement: HTMLDivElement }>) { +export async function createShadowRootOverlay(ctx: ContentScriptContext, component: Component<{ rootElement: HTMLDivElement, writingToolsRoot: HTMLDivElement, gmailToolsRoot: HTMLDivElement }>) { const existingUI = document.querySelector(CONTENT_UI_SHADOW_ROOT_NAME) if (existingUI) { try { @@ -39,8 +39,12 @@ export async function createShadowRootOverlay(ctx: ContentScriptContext, compone async onMount(uiContainer, shadowRoot, shadowHost) { await loadStyleSheet(shadowRoot) const rootElement = document.createElement('div') + const writingToolsRoot = document.createElement('div') + const gmailToolsRoot = document.createElement('div') const toastRoot = document.createElement('div') uiContainer.appendChild(rootElement) + uiContainer.appendChild(writingToolsRoot) + uiContainer.appendChild(gmailToolsRoot) uiContainer.appendChild(toastRoot) shadowHost.dataset.testid = 'nativemind-container' shadowHost.style.setProperty('position', 'fixed') @@ -48,7 +52,7 @@ export async function createShadowRootOverlay(ctx: ContentScriptContext, compone shadowHost.style.setProperty('left', '0px') shadowHost.style.setProperty('z-index', 'calc(infinity)') const pinia = createPinia() - const app = createApp(component, { rootElement }) + const app = createApp(component, { rootElement, writingToolsRoot, gmailToolsRoot }) app.use(await createI18nInstance()) app.use(initToast(toastRoot)) app.use(pinia) diff --git a/entrypoints/content/utils/gmail/email-extractor.ts b/entrypoints/content/utils/gmail/email-extractor.ts new file mode 100644 index 00000000..6ca8e735 --- /dev/null +++ b/entrypoints/content/utils/gmail/email-extractor.ts @@ -0,0 +1,266 @@ +import logger from '@/utils/logger' + +export interface EmailData { + from: string + date: string + to: string[] + cc: string[] + subject: string + body: string + attachments?: { filename: string, size: string }[] +} + +export interface ThreadData { + subject: string + emails: EmailData[] +} + +/** + * Utility class for extracting email content from Gmail's DOM structure + * Based on Gmail's current DOM selectors (may need updates as Gmail evolves) + */ +export class EmailExtractor { + private log = logger.child('email-extractor') + + private querySelectorSafe(parent: Document | Element, selector: string): T | null { + try { + return parent.querySelector(selector) + } + catch (error) { + this.log.warn('Failed to query selector:', selector, error) + return null + } + } + + private querySelectorSafeAll(parent: Document | Element, selector: string): T[] { + try { + return Array.from(parent.querySelectorAll(selector)) + } + catch (error) { + this.log.warn('Failed to query selector all:', selector, error) + return [] + } + } + + private async sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) + } + + /** + * Extract email content from Gmail's DOM + * Based on the extraction logic from mapify-ext + */ + async extractEmailContent(threadElement: HTMLElement): Promise<{ subject: string, emails: EmailData[] }> { + try { + const document = window.document + + // Extract subject + const subject = this.querySelectorSafe(document, '[jsname="r4nke"]')?.innerText + || this.querySelectorSafe(threadElement, 'h2')?.innerText + || 'No Subject' + + // Expand all emails in the thread + this.querySelectorSafe(document, '.adx')?.click() + const nodeList = this.querySelectorSafeAll(threadElement, '.G3') + const emails: EmailData[] = [] + + if (!nodeList.length) { + // Fallback: extract visible content if no individual emails found + const bodyContent = this.querySelectorSafe(threadElement, '.a3s')?.innerText + || threadElement.innerText || '' + return { subject, emails: [{ from: '', date: '', to: [], cc: [], subject, body: bodyContent }] } + } + + for (const htmlItem of nodeList) { + // Click to expand email details + const expandTr = this.querySelectorSafe(htmlItem, 'tr') + expandTr?.click() // first click for expand dom + expandTr?.click() // second click for close dom + await this.sleep(10) + + // Get email details panel + const trigger = this.querySelectorSafe(htmlItem, '.ajz') + trigger?.click() + + const panel = this.querySelectorSafe(htmlItem, '.ajA') + const header = this.querySelectorSafe(htmlItem, '.iv') + + if (!header) continue + + // Extract email metadata + const from = this.querySelectorSafe(header, '.qu')?.innerText || '' + const date = this.querySelectorSafe(header, '.g3')?.innerText || '' + const body = this.querySelectorSafe(htmlItem, '.a3s')?.innerText || '' + + // Extract attachments + const attachments: { filename: string, size: string }[] = [] + const attachContainer = this.querySelectorSafe(htmlItem, '.aQH') + if (attachContainer) { + const attachList = this.querySelectorSafeAll(attachContainer, '.aZo') + for (const attach of attachList) { + const filename = this.querySelectorSafe(attach, '.aV3')?.innerText || '' + const size = this.querySelectorSafe(attach, '.SaH2Ve')?.innerText || '' + if (filename) attachments.push({ filename, size }) + } + } + + // Hide details panel + if (panel) { + panel.style.display = 'none' + panel.style.visibility = 'hidden' + } + + // Extract recipient details + const detailDom = this.querySelectorSafe(htmlItem, '.ajA') + const detailsDoms: HTMLElement[] = [] + if (detailDom) { + const detailDomList = this.querySelectorSafeAll(detailDom, 'tr') + detailDomList.forEach((dom) => { + const emailElement = this.querySelectorSafe(dom, '[email]') + if (emailElement) { + detailsDoms.push(emailElement as HTMLElement) + } + }) + } + + // Parse recipient information + const emailInfo = detailsDoms?.map((dom: HTMLElement) => { + const input = dom.parentElement?.innerText || '' + const regex = /([^<,>]+)\s*<([^<,>]+)>/g + const emailAddresses = [] + let result + while ((result = regex.exec(input)) !== null) { + emailAddresses.push(result[0]) + } + return emailAddresses + }) + + const index = detailsDoms.length >= 3 ? 2 : 1 + emails.push({ + from, + date, + to: emailInfo[index] || [], + cc: emailInfo[index + 1] || [], + subject, + body, + attachments: attachments.length > 0 ? attachments : undefined, + }) + } + + // Format the extracted content + return { + subject, + emails, + } + } + catch (error) { + this.log.error('Failed to extract email thread content:', error) + // Fallback to simple text extraction + return { + subject: 'Failed to extract subject', + emails: [{ + from: '', + date: '', + to: [], + cc: [], + subject: '', + body: threadElement.innerText || 'Failed to extract email content', + }], + } + } + } + + /** + * Format thread data into readable string + */ + formatThreadContent(threadData: ThreadData): string { + let content = `Subject: ${threadData.subject}\n\n` + + threadData.emails.forEach((email, index) => { + content += `--- Email ${index + 1} ---\n` + content += `From: ${email.from}\n` + content += `Date: ${email.date}\n` + if (email.to?.length) content += `To: ${email.to.join(', ')}\n` + if (email.cc?.length) content += `CC: ${email.cc.join(', ')}\n` + if (email.attachments?.length) { + content += `Attachments: ${email.attachments.map((a) => `${a.filename} (${a.size})`).join(', ')}\n` + } + content += `\n${email.body}\n\n` + }) + + return content + } + + /** + * Extract draft content from compose area + */ + extractDraftContent(composeElement: HTMLElement): string { + const draftBody = this.querySelectorSafe(composeElement, '[contenteditable="true"]') + return draftBody?.innerText || '' + } + + /** + * Extract recipients from compose area + */ + extractRecipients(composeElement: HTMLElement): { to: string[], cc: string[], bcc: string[] } { + const extractRecipientsFromListbox = (listbox: HTMLElement): string[] => { + const recipients: string[] = [] + const options = this.querySelectorSafeAll(listbox, '[role="option"][data-hovercard-id]') + + for (const option of options) { + const email = option.getAttribute('data-hovercard-id') || '' + const name = option.getAttribute('data-name') || '' + + if (email) { + if (name && name !== email) { + recipients.push(`${name} <${email}>`) + } + else { + recipients.push(email) + } + } + } + + return recipients + } + + // Use specific selectors for each recipient type within the compose dialog + const toListboxes = this.querySelectorSafeAll(composeElement, '[aria-label="To"] [role="listbox"]') + const ccListboxes = this.querySelectorSafeAll(composeElement, '[aria-label="Cc"] [role="listbox"]') + const bccListboxes = this.querySelectorSafeAll(composeElement, '[aria-label="Bcc"] [role="listbox"]') + + // Extract recipients from each type of listbox + const toRecipients: string[] = [] + const ccRecipients: string[] = [] + const bccRecipients: string[] = [] + + // Extract TO recipients + for (const listbox of toListboxes) { + toRecipients.push(...extractRecipientsFromListbox(listbox)) + } + + // Extract CC recipients + for (const listbox of ccListboxes) { + ccRecipients.push(...extractRecipientsFromListbox(listbox)) + } + + // Extract BCC recipients + for (const listbox of bccListboxes) { + bccRecipients.push(...extractRecipientsFromListbox(listbox)) + } + + return { + to: toRecipients, + cc: ccRecipients, + bcc: bccRecipients, + } + } + + /** + * Extract subject from compose area + */ + extractSubject(composeElement: HTMLElement): string { + const subjectField = this.querySelectorSafe(composeElement, 'input[name="subjectbox"]') + return subjectField?.value || '' + } +} diff --git a/entrypoints/content/utils/gmail/settings.ts b/entrypoints/content/utils/gmail/settings.ts new file mode 100644 index 00000000..e92be172 --- /dev/null +++ b/entrypoints/content/utils/gmail/settings.ts @@ -0,0 +1,41 @@ +import { getUserConfig } from '@/utils/user-config' + +export class GmailSettingsManager { + private userConfig: Awaited> | null = null + + async init() { + if (!this.userConfig) { + this.userConfig = await getUserConfig() + } + } + + async isEmailToolsEnabled(): Promise { + await this.init() + return this.userConfig!.emailTools.enable.toRef().value === true + } + + async getOutputLanguage(): Promise { + await this.init() + return this.userConfig!.emailTools.outputLanguage.toRef().value + } + + async getOutputStyle(): Promise { + await this.init() + return this.userConfig!.emailTools.outputStyle.toRef().value + } + + async getSummaryPrompt(): Promise { + await this.init() + return this.userConfig!.emailTools.summary.systemPrompt.toRef().value + } + + async getReplyPrompt(): Promise { + await this.init() + return this.userConfig!.emailTools.reply.systemPrompt.toRef().value + } + + async getComposePrompt(): Promise { + await this.init() + return this.userConfig!.emailTools.compose.systemPrompt.toRef().value + } +} diff --git a/entrypoints/content/utils/page-injection/gmail-tools.ts b/entrypoints/content/utils/page-injection/gmail-tools.ts new file mode 100644 index 00000000..02b23d91 --- /dev/null +++ b/entrypoints/content/utils/page-injection/gmail-tools.ts @@ -0,0 +1,547 @@ +import { onScopeDispose, watchEffect } from 'vue' + +import LogoSvg from '@/assets/icons/logo.svg?raw' +import { useDocumentLoaded } from '@/composables/useDocumentLoaded' +import { useLogger } from '@/composables/useLogger' +import { c2bRpc } from '@/utils/rpc' +import { toggleContainer } from '@/utils/rpc/content-main-world-fns' +import { sleep } from '@/utils/sleep' + +import { useGmailDetector } from '../../composables/useGmailDetector' +import { EmailExtractor } from '../gmail/email-extractor' + +const logger = useLogger() +const NATIVEMIND_GMAIL_SUMMARY_BUTTON_CLASS = 'nativemind-gmail-summary-btn' +const NATIVEMIND_GMAIL_REPLY_BUTTON_CLASS = 'nativemind-gmail-reply-btn' +const NATIVEMIND_GMAIL_COMPOSE_BUTTON_CLASS = 'nativemind-gmail-compose-btn' + +const LogoIcon = LogoSvg.replace(' { + // Return early if button is disabled + if (button.disabled) { + return + } + ev.stopImmediatePropagation() + ev.preventDefault() + + await toggleContainer() + + // wait 2 seconds for sidepanel to load + await sleep(2000) + + try { + button.disabled = true + button.innerHTML = ` +
+
+
+
+
+
+
+
+
+
+ ` + + const emailExtractor = new EmailExtractor() + const emailData = await emailExtractor.extractEmailContent(threadElement) + logger.debug('emailContent:', emailData) + + const emailContent = emailExtractor.formatThreadContent(emailData) + const tabInfo = await c2bRpc.getTabInfo() + + // Forward Gmail action to sidepanel via background + await c2bRpc.forwardGmailAction('summary', { emailContent }, tabInfo) + + // wait 2 seconds for sidepanel to load + await sleep(2000) + + button.innerHTML = ` + ${LogoIcon} + AI Summary + ` + button.disabled = false + } + catch (error) { + logger.error('Failed to summarize email:', error) + button.innerHTML = ` + ${LogoIcon} + AI Summary + ` + button.disabled = false + } + }) + + return button +} + +function makeReplyButton() { + const button = document.createElement('button') + button.innerHTML = ` + ${LogoIcon} + AI Reply + ` + + // Inject CSS styles for the button + const styleId = 'nativemind-gmail-reply-btn-styles' + if (!document.getElementById(styleId)) { + const style = document.createElement('style') + style.id = styleId + style.textContent = ` + .${NATIVEMIND_GMAIL_REPLY_BUTTON_CLASS} { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 10px; + background: #FBF8F4; + color: #3c4043; + border: 1px solid #dadce0; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.1); + transition: all 0.2s ease; + font-family: 'Google Sans', Roboto, Arial, sans-serif; + white-space: nowrap; + margin-left: 8px; + z-index: 1; + } + + .${NATIVEMIND_GMAIL_REPLY_BUTTON_CLASS}:hover:not(:disabled) { + background: #EAECEF; + box-shadow: 0 2px 6px 0 rgba(60, 64, 67, 0.15); + } + + .${NATIVEMIND_GMAIL_REPLY_BUTTON_CLASS}:disabled { + background: #f1f3f4; + color: #5f6368; + cursor: not-allowed; + opacity: 0.6; + } + ` + document.head.appendChild(style) + } + + button.className = NATIVEMIND_GMAIL_REPLY_BUTTON_CLASS + + button.addEventListener('click', async (ev) => { + ev.stopImmediatePropagation() + ev.preventDefault() + + try { + // Get button position for popup positioning + const buttonRect = button.getBoundingClientRect() + const buttonData = { + el: button, + x: buttonRect.left, // 左边位置 + y: buttonRect.top, + width: buttonRect.width, + height: buttonRect.height, + } + + // Dispatch custom event to show reply card with button position + const showReplyEvent = new CustomEvent('nativemind:show-reply-card', { + detail: { buttonData }, + bubbles: true, + }) + document.dispatchEvent(showReplyEvent) + } + catch (error) { + logger.error('Failed to show Gmail reply card:', error) + } + }) + + return button +} + +function makeComposeButton() { + const button = document.createElement('button') + button.innerHTML = ` + ${LogoIcon} + AI Polish + ` + + // Inject CSS styles for the button + const styleId = 'nativemind-gmail-compose-btn-styles' + if (!document.getElementById(styleId)) { + const style = document.createElement('style') + style.id = styleId + style.textContent = ` + .${NATIVEMIND_GMAIL_COMPOSE_BUTTON_CLASS} { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 10px; + background: #FBF8F4; + color: #3c4043; + border: 1px solid #dadce0; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + box-shadow: 0 1px 3px 0 rgba(60, 64, 67, 0.1); + transition: all 0.2s ease; + font-family: 'Google Sans', Roboto, Arial, sans-serif; + white-space: nowrap; + margin-left: 8px; + z-index: 1; + } + + .${NATIVEMIND_GMAIL_COMPOSE_BUTTON_CLASS}:hover:not(:disabled) { + background: #EAECEF; + box-shadow: 0 2px 6px 0 rgba(60, 64, 67, 0.15); + } + + .${NATIVEMIND_GMAIL_COMPOSE_BUTTON_CLASS}:disabled { + background: #f1f3f4; + color: #5f6368; + cursor: not-allowed; + opacity: 0.6; + } + ` + document.head.appendChild(style) + } + + button.className = NATIVEMIND_GMAIL_COMPOSE_BUTTON_CLASS + + button.addEventListener('click', async (ev) => { + ev.stopImmediatePropagation() + ev.preventDefault() + + try { + // Get button position for popup positioning + const buttonRect = button.getBoundingClientRect() + const buttonData = { + el: button, + x: buttonRect.left, + y: buttonRect.top, + width: buttonRect.width, + height: buttonRect.height, + } + + // Dispatch custom event to show compose card with button position + const showComposeEvent = new CustomEvent('nativemind:show-compose-card', { + detail: { buttonData }, + bubbles: true, + }) + document.dispatchEvent(showComposeEvent) + } + catch (error) { + logger.error('Failed to show Gmail compose card:', error) + } + }) + + return button +} + +function findSummaryButtonInjectionPoint(threadElement: HTMLElement): { parent: HTMLElement, insertAfter?: HTMLElement } | null { + // Rule 1: If Gmail's native Summarize button exists, inject to its parent + const existingSummaryBtn = threadElement.querySelector('.x4Und') + if (existingSummaryBtn?.parentNode) { + return { parent: existingSummaryBtn.parentNode as HTMLElement } + } + + // Rule 2: Find .nH.iY vertical layout and inject after its first child + const verticalLayout = threadElement.querySelector('.nH.iY') + if (verticalLayout) { + const firstChild = verticalLayout.firstElementChild + if (firstChild) { + return { parent: verticalLayout as HTMLElement, insertAfter: firstChild as HTMLElement } + } + } + + return null +} + +function injectReplyButton() { + if (location.hostname !== 'mail.google.com') return + + // Find elements with class 'btC' under role="listitem" + const btcElements = document.querySelectorAll('[role="listitem"] .btC') + + btcElements.forEach((btcElement) => { + // Check if our reply button already exists in this btC element + const existingReplyBtn = btcElement.querySelector('.nativemind-gmail-reply-btn') + if (existingReplyBtn) { + return + } + + const replyButton = makeReplyButton() + + // Insert as second child + if (btcElement.children.length >= 1) { + // Insert after the first child + const firstChild = btcElement.children[0] + firstChild.insertAdjacentElement('afterend', replyButton) + } + else { + // If no children, just append + btcElement.appendChild(replyButton) + } + + logger.debug('Reply button injected under btC element') + }) +} + +function injectComposeButton() { + if (location.hostname !== 'mail.google.com') return + + // Find elements with class 'btC' under elements with role="dialog" + const btcElements = document.querySelectorAll('[role="dialog"] .btC') + + btcElements.forEach((btcElement) => { + // Check if our compose button already exists in this btC element + const existingComposeBtn = btcElement.querySelector('.nativemind-gmail-compose-btn') + if (existingComposeBtn) { + return + } + + const composeButton = makeComposeButton() + + // Insert as second child + if (btcElement.children.length >= 1) { + // Insert after the first child + const firstChild = btcElement.children[0] + firstChild.insertAdjacentElement('afterend', composeButton) + } + else { + // If no children, just append + btcElement.appendChild(composeButton) + } + + logger.debug('Compose button injected under btC element') + }) +} + +function injectSummaryButton(currentThreadElement: HTMLElement | null) { + if (location.hostname !== 'mail.google.com' || !currentThreadElement) return + + // Check if our summary button already exists in this thread + const existingSummaryBtn = currentThreadElement.querySelector('.nativemind-gmail-summary-btn') + if (existingSummaryBtn) { + return + } + + const injectionInfo = findSummaryButtonInjectionPoint(currentThreadElement) + if (injectionInfo) { + if (injectionInfo.insertAfter) { + const summaryButton = makeSummaryButton(currentThreadElement, 'margin: 8px 0 0 72px') // align with title + // Insert after the specified element (Rule 2) + injectionInfo.insertAfter.insertAdjacentElement('afterend', summaryButton) + } + else { + const summaryButton = makeSummaryButton(currentThreadElement) + // Append to parent (Rule 1) + injectionInfo.parent.appendChild(summaryButton) + } + + logger.debug('Summary button injected for thread:', currentThreadElement.getAttribute('data-thread-id')) + } +} + +export function useInjectGmailSummaryButtons() { + let disposed = false + const { currentThread } = useGmailDetector() + + onScopeDispose(() => { + disposed = true + const buttons = document.querySelectorAll(`.${NATIVEMIND_GMAIL_SUMMARY_BUTTON_CLASS}`) + buttons.forEach((button) => { + button.remove() + }) + }) + + function inject() { + if (disposed) { + logger.warn('Gmail summary button injection already disposed') + return + } + if (location.hostname !== 'mail.google.com') return + + // Watch for currentThread changes and inject when it changes + watchEffect(() => { + if (currentThread.value?.element) { + injectSummaryButton(currentThread.value.element) + } + }) + } + + return inject +} + +export function useInjectGmailReplyButtons() { + let disposed = false + + onScopeDispose(() => { + disposed = true + const buttons = document.querySelectorAll(`.${NATIVEMIND_GMAIL_REPLY_BUTTON_CLASS}`) + buttons.forEach((button) => { + button.remove() + }) + }) + + function inject() { + if (disposed) { + logger.warn('Gmail reply button injection already disposed') + return + } + if (location.hostname !== 'mail.google.com') return + + // Use MutationObserver to watch for DOM changes and inject buttons + const observer = new MutationObserver(() => { + injectReplyButton() + }) + + observer.observe(document.body, { + childList: true, + subtree: true, + }) + + // Initial injection + injectReplyButton() + + onScopeDispose(() => { + observer.disconnect() + }) + } + + return inject +} + +export function useInjectGmailComposeButtons() { + let disposed = false + + onScopeDispose(() => { + disposed = true + const buttons = document.querySelectorAll(`.${NATIVEMIND_GMAIL_COMPOSE_BUTTON_CLASS}`) + buttons.forEach((button) => { + button.remove() + }) + }) + + function inject() { + if (disposed) { + logger.warn('Gmail compose button injection already disposed') + return + } + if (location.hostname !== 'mail.google.com') return + + // Use MutationObserver to watch for DOM changes and inject buttons + const observer = new MutationObserver(() => { + injectComposeButton() + }) + + observer.observe(document.body, { + childList: true, + subtree: true, + }) + + // Initial injection + injectComposeButton() + + onScopeDispose(() => { + observer.disconnect() + }) + } + + return inject +} + +export async function useInjectGmailTools() { + const injections = [ + useInjectGmailSummaryButtons(), + useInjectGmailReplyButtons(), + useInjectGmailComposeButtons(), + ] + + useDocumentLoaded(() => { + injections.forEach((inject) => { + inject() + }) + }) +} diff --git a/entrypoints/settings/components/EmailToolsSettings/index.vue b/entrypoints/settings/components/EmailToolsSettings/index.vue new file mode 100644 index 00000000..24a04078 --- /dev/null +++ b/entrypoints/settings/components/EmailToolsSettings/index.vue @@ -0,0 +1,374 @@ +