From c598b864eff2b8e5787da6326e81c3d7a1f4a511 Mon Sep 17 00:00:00 2001 From: "Abderraouf Ghrissi (abgh)" Date: Sat, 4 Apr 2026 19:37:52 +0200 Subject: [PATCH] Implement Gmail activity tracking with optional metadata extraction ActivityWatch is used in timesheet tracking, and knowing just "reading" or "composing" email info is not very useful on its own. Extracting involved email metadata (Sender, Recipients, Subject) helps determine the context of the activity relative to project models and workflows in the used software. A new setting has been added to allow users to enable or disable Gmail tracking. Also added a build.sh script to simplify the build and test process for Chrome and Firefox. ui related changes: https://github.com/ActivityWatch/aw-webui/pull/796 --- build.sh | 8 ++ src/background/heartbeat.ts | 110 +++++++++++++++++++------ src/background/main.ts | 10 ++- src/content/gmail.ts | 158 ++++++++++++++++++++++++++++++++++++ src/manifest.json | 12 ++- src/popup/index.html | 9 ++ src/popup/main.ts | 19 +++++ src/storage.ts | 6 ++ 8 files changed, 302 insertions(+), 30 deletions(-) create mode 100755 build.sh create mode 100644 src/content/gmail.ts diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..7da3181 --- /dev/null +++ b/build.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +make build-chrome && \ +mkdir -p artifacts/chrome && \ +unzip -o artifacts/chrome.zip -d artifacts/chrome + +make build-firefox && \ +mkdir -p artifacts/firefox && \ +unzip -o artifacts/firefox.zip -d artifacts/firefox diff --git a/src/background/heartbeat.ts b/src/background/heartbeat.ts index 8ad40a0..da03696 100644 --- a/src/background/heartbeat.ts +++ b/src/background/heartbeat.ts @@ -3,9 +3,76 @@ import { getActiveWindowTab, getTab, getTabs } from './helpers' import config from '../config' import { AWClient, IEvent } from 'aw-client' import { getBucketId, sendHeartbeat } from './client' -import { getEnabled, getHeartbeatData, setHeartbeatData } from '../storage' +import { getEnabled, getHeartbeatData, setHeartbeatData, getGmailEnabled } from '../storage' import deepEqual from 'deep-equal' +export function setupMessageListener(client: AWClient) { + browser.runtime.onMessage.addListener( + async (message: any, sender: browser.Runtime.MessageSender) => { + const enabled = await getEnabled(); + const gmailEnabled = await getGmailEnabled(); + if (!enabled || !gmailEnabled) return; + + if (message.type === 'AW_GMAIL_HEARTBEAT') { + const tab = sender.tab; + if (!tab || !tab.url || !tab.title) return; + if (!tab.url.includes('mail.google.com')) return; + const tabs = await getTabs(); + + const data: IEvent['data'] = { + url: tab.url, + title: tab.title, + audible: tab.audible ?? false, + incognito: tab.incognito, + tabCount: tabs.length, + ...message.data, + }; + await performHeartbeat(client, data); + } + }, + ) +} + +async function performHeartbeat( + client: AWClient, + data: IEvent['data'], + options: { finalizeOnly?: boolean } = {} +) { + const bucketId = await getBucketId() + const now = new Date() + const previousData = await getHeartbeatData() + if (previousData && !deepEqual(previousData, data)) { + console.debug('[Background] Activity changed, finalizing previous session', previousData) + await sendHeartbeat( + client, + bucketId, + new Date(now.getTime() - 1), + previousData, + config.heartbeat.intervalInSeconds + 20, + ).catch(() => {}) + } + + if (options.finalizeOnly) { + if (previousData) { + await browser.storage.local.remove('heartbeatData'); + } + return; + } + + console.debug('[Background] Sending heartbeat', data) + await sendHeartbeat( + client, + bucketId, + now, + data, + config.heartbeat.intervalInSeconds + 20, + ).catch((err: unknown) => { + console.error('[Background] Failed to send heartbeat:', err); + }) + + await setHeartbeatData(data) +} + async function heartbeat( client: AWClient, tab: browser.Tabs.Tab | undefined, @@ -27,7 +94,6 @@ async function heartbeat( return } - const now = new Date() const data: IEvent['data'] = { url: tab.url, title: tab.title, @@ -35,26 +101,16 @@ async function heartbeat( incognito: tab.incognito, tabCount: tabCount, } - const previousData = await getHeartbeatData() - if (previousData && !deepEqual(previousData, data)) { - console.debug('Sending heartbeat for previous data', previousData) - await sendHeartbeat( - client, - await getBucketId(), - new Date(now.getTime() - 1), - previousData, - config.heartbeat.intervalInSeconds + 20, - ) + + const gmailEnabled = await getGmailEnabled(); + if (gmailEnabled && tab.url.includes('mail.google.com')) { + // Sharp cut: finalize the previous activity (e.g. if we came from Google Search) + // but don't start the 'Generic' Gmail event. Gmail.ts will do that with metadata. + await performHeartbeat(client, data, { finalizeOnly: true }); + return; } - console.debug('Sending heartbeat', data) - await sendHeartbeat( - client, - await getBucketId(), - now, - data, - config.heartbeat.intervalInSeconds + 20, - ) - await setHeartbeatData(data) + + await performHeartbeat(client, data); } export const sendInitialHeartbeat = async (client: AWClient) => { @@ -76,9 +132,9 @@ export const heartbeatAlarmListener = export const tabActivatedListener = (client: AWClient) => - async (activeInfo: browser.Tabs.OnActivatedActiveInfoType) => { - const tab = await getTab(activeInfo.tabId) - const tabs = await getTabs() - console.debug('Sending heartbeat for tab activation', tab) - await heartbeat(client, tab, tabs.length) - } + async (activeInfo: browser.Tabs.OnActivatedActiveInfoType) => { + const tab = await getTab(activeInfo.tabId) + const tabs = await getTabs() + console.debug('Sending heartbeat for tab activation', tab) + await heartbeat(client, tab, tabs.length) + } diff --git a/src/background/main.ts b/src/background/main.ts index a1b092b..300d6ec 100644 --- a/src/background/main.ts +++ b/src/background/main.ts @@ -4,6 +4,7 @@ import { heartbeatAlarmListener, sendInitialHeartbeat, tabActivatedListener, + setupMessageListener, } from './heartbeat' import { getClient, detectHostname } from './client' import { @@ -15,6 +16,7 @@ import { setHostname, waitForEnabled, } from '../storage' +import { AWClient } from 'aw-client' async function getIsConsentRequired() { if (!config.requireConsent) return false @@ -24,7 +26,7 @@ async function getIsConsentRequired() { .catch(() => true) } -async function autodetectHostname() { +async function autodetectHostname(client: AWClient) { const hostname = await getHostname() if (hostname === undefined) { const detectedHostname = await detectHostname(client) @@ -57,13 +59,17 @@ browser.runtime.onInstalled.addListener(async () => { }) } - await autodetectHostname() + await autodetectHostname(client) }) console.debug('Creating alarms and tab listeners') browser.alarms.create(config.heartbeat.alarmName, { periodInMinutes: Math.floor(config.heartbeat.intervalInSeconds / 60), }) + +// Set up Gmail message listener (other watchers will be added later) +setupMessageListener(client) + browser.alarms.onAlarm.addListener(heartbeatAlarmListener(client)) browser.tabs.onActivated.addListener(tabActivatedListener(client)) diff --git a/src/content/gmail.ts b/src/content/gmail.ts new file mode 100644 index 0000000..fd1ba62 --- /dev/null +++ b/src/content/gmail.ts @@ -0,0 +1,158 @@ +import browser from 'webextension-polyfill' +import deepEqual from 'deep-equal' +import config from '../config' + +let lastData: any | null = null; + +function isExtensionValid() { + return typeof browser !== 'undefined' && !!browser.storage && !!browser.runtime?.id; +} + + +function getComposeMetadata(form: HTMLElement) { + const getRecipients = (name: string) => + Array.from( + form.querySelectorAll(`div[name="${name}"] [data-hovercard-id]`), + ).map((el) => el.getAttribute('data-hovercard-id')) + .filter(Boolean) as string[]; + + return { + gmail_activity: 'composing_email', + subject: (form.querySelector('input[name="subjectbox"]') as HTMLInputElement)?.value || '', + to: getRecipients('to'), + cc: getRecipients('cc'), + bcc: getRecipients('bcc'), + }; +} + +function sendGmailHeartbeat() { + if (!isExtensionValid()) { + // Don't kill tracking — just skip this tick. + // The onChanged listener will handle re-evaluation. + return; + } + if (document.visibilityState === 'hidden') { + return; + } + + const hash = window.location.hash; + // for simplity in MVP: + // - if many emails forms are open, we only track the first one + const form = document.querySelector('div[role="dialog"] form') as HTMLElement | null; + + let activity = 'reading_inbox'; + let meta: any = { gmail_activity: activity }; + + if (form) { + activity = 'composing_email'; + meta = getComposeMetadata(form); + } else if ( + hash.includes('inbox/') || + hash.includes('sent/') || + hash.includes('all/') + ) { + /** + * NOTE on Fragility: The selectors below (span.gD, .gE, h2.hP) are internal + * Gmail class names. These are not part of a stable API and may change + * during Gmail frontend updates. High-fidelity tracking may require + * maintenance if these selectors break. + */ + const fromEl = document.querySelector('span.gD'); + const from = + fromEl?.getAttribute('email') || + fromEl?.getAttribute('data-hovercard-id') || + (fromEl as HTMLElement)?.innerText || + ''; + const to = Array.from( + document.querySelectorAll('.gE [email], .gE [data-hovercard-id]'), + ) + .map( + (el) => el.getAttribute('email') || el.getAttribute('data-hovercard-id'), + ) + .filter((e) => e && e !== from) as string[]; + + activity = 'reading_email'; + meta = { + gmail_activity: activity, + subject: (document.querySelector('h2.hP') as HTMLElement)?.innerText || '', + from, + to, + }; + } + + if (!deepEqual(lastData, meta)) { + lastData = meta; + browser.runtime.sendMessage({ + type: 'AW_GMAIL_HEARTBEAT', + data: meta + }).catch(() => {}) + } +} + +let detectIntervalId: ReturnType | null = null; +let pulseIntervalId: ReturnType | null = null; + +function startTracking() { + if (detectIntervalId !== null) { + return; + } + + detectIntervalId = setInterval(sendGmailHeartbeat, 5000); + pulseIntervalId = setInterval(() => { + if (!isExtensionValid()) { + return; + } + if (lastData && document.visibilityState === 'visible') { + try { + browser.runtime.sendMessage({ + type: 'AW_GMAIL_HEARTBEAT', + data: lastData + }).catch(() => {}) + } catch (err) { + // Extension context invalidated + } + } + }, config.heartbeat.intervalInSeconds * 1000); + + sendGmailHeartbeat(); +} + +async function refreshTracking() { + if (!isExtensionValid()) { + return; + } + try { + const settings = await browser.storage.local.get(['gmailEnabled', 'enabled']); + const shouldTrack = Boolean(settings.gmailEnabled && settings.enabled); + + if (shouldTrack) { + startTracking(); + } else { + stopTracking(); + } + } catch (err) { + console.error('[Gmail Content] Failed to refresh tracking state', err); + } +} + +function stopTracking() { + if (detectIntervalId !== null) { + clearInterval(detectIntervalId) + detectIntervalId = null; + } + if (pulseIntervalId !== null) { + clearInterval(pulseIntervalId) + pulseIntervalId = null; + } + lastData = null; +} + +browser.storage.local.get(['gmailEnabled', 'enabled']).then(() => { + refreshTracking(); +}) + +browser.storage.onChanged.addListener((changes) => { + if ('gmailEnabled' in changes || 'enabled' in changes) { + refreshTracking(); + } +}) diff --git a/src/manifest.json b/src/manifest.json index 477e7df..5dc03f2 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -64,5 +64,15 @@ "gecko": { "id": "{ef87d84c-2127-493f-b952-5b4e744245bc}" } - } + }, + "content_scripts": [ + { + "matches": [ + "*://mail.google.com/*" + ], + "js": [ + "src/content/gmail.ts" + ] + } + ] } diff --git a/src/popup/index.html b/src/popup/index.html index 9b13100..245efe7 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -48,6 +48,15 @@ + + + + Gmail details: + + + + + Connected: diff --git a/src/popup/main.ts b/src/popup/main.ts index fec25f2..8a07827 100644 --- a/src/popup/main.ts +++ b/src/popup/main.ts @@ -10,6 +10,8 @@ import { watchSyncSuccess, getBrowserName, getHostname, + getGmailEnabled, + setGmailEnabled, } from '../storage' function setConnected(connected: boolean | undefined) { @@ -31,6 +33,7 @@ function setSyncDate(date: string | undefined) { async function renderStatus() { const baseUrl = await getBaseUrl() const enabled = await getEnabled() + const gmailEnabled = await getGmailEnabled() const syncStatus = await getSyncStatus() const consentStatus = await getConsentStatus() const browserName = await getBrowserName() @@ -42,6 +45,12 @@ async function renderStatus() { throw Error('Enable checkbox is not an input') enabledCheckbox.checked = enabled + // Gmail checkbox + const gmailEnabledCheckbox = document.getElementById('status-gmail-enabled-checkbox') + if (!(gmailEnabledCheckbox instanceof HTMLInputElement)) + throw Error('Gmail enable checkbox is not an input') + gmailEnabledCheckbox.checked = gmailEnabled + // Consent Button const showConsentBtn = document.getElementById('status-consent-btn') if (!(showConsentBtn instanceof HTMLButtonElement)) @@ -49,9 +58,11 @@ async function renderStatus() { if (!consentStatus.required || consentStatus.consent) { enabledCheckbox.removeAttribute('disabled') + gmailEnabledCheckbox.removeAttribute('disabled') showConsentBtn.style.setProperty('display', 'none') } else { enabledCheckbox.setAttribute('disabled', '') + gmailEnabledCheckbox.setAttribute('disabled', '') showConsentBtn.style.setProperty('display', 'inline-block') } @@ -99,6 +110,14 @@ function domListeners() { setEnabled(enabled) }) + const gmailEnabledCheckbox = document.getElementById('status-gmail-enabled-checkbox') + if (!(gmailEnabledCheckbox instanceof HTMLInputElement)) + throw Error('Gmail enable checkbox is not an input') + gmailEnabledCheckbox.addEventListener('change', async () => { + const gmailEnabled = gmailEnabledCheckbox.checked + setGmailEnabled(gmailEnabled) + }) + const consentButton = document.getElementById('status-consent-btn')! consentButton.addEventListener('click', () => { browser.tabs.create({ diff --git a/src/storage.ts b/src/storage.ts index 60cfdd3..0422dee 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -101,3 +101,9 @@ export const getHostname = (): Promise => .then((data: StorageData) => data.hostname as string | undefined) export const setHostname = (hostname: Hostname) => browser.storage.local.set({ hostname }) + +type GmailEnabled = boolean +export const getGmailEnabled = (): Promise => + browser.storage.local.get('gmailEnabled').then((_) => Boolean(_.gmailEnabled)) +export const setGmailEnabled = (gmailEnabled: GmailEnabled) => + browser.storage.local.set({ gmailEnabled })