Skip to content

Commit 5a79cb0

Browse files
committed
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: ActivityWatch/aw-webui#796
1 parent f391889 commit 5a79cb0

File tree

10 files changed

+312
-31
lines changed

10 files changed

+312
-31
lines changed

build.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#!/usr/bin/env bash
2+
make build-chrome && \
3+
mkdir -p artifacts/chrome && \
4+
unzip -o artifacts/chrome.zip -d artifacts/chrome
5+
6+
make build-firefox && \
7+
mkdir -p artifacts/firefox && \
8+
unzip -o artifacts/firefox.zip -d artifacts/firefox

src/background/client.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import { emitNotification, getBrowser, logHttpError } from './helpers'
66
import { getHostname, getSyncStatus, setSyncStatus } from '../storage'
77

88
export const getClient = () =>
9-
new AWClient('aw-client-web', { testing: config.isDevelopment })
9+
new AWClient('aw-client-web', {
10+
testing: config.isDevelopment,
11+
baseURL: (config as any).baseURL, // for testing locally, will be removed once PR ready
12+
})
1013

1114
// TODO: We might want to get the hostname somehow, maybe like this:
1215
// https://stackoverflow.com/questions/28223087/how-can-i-allow-firefox-or-chrome-to-read-a-pcs-hostname-or-other-assignable

src/background/heartbeat.ts

Lines changed: 80 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,73 @@ import { getActiveWindowTab, getTab, getTabs } from './helpers'
33
import config from '../config'
44
import { AWClient, IEvent } from 'aw-client'
55
import { getBucketId, sendHeartbeat } from './client'
6-
import { getEnabled, getHeartbeatData, setHeartbeatData } from '../storage'
6+
import { getEnabled, getHeartbeatData, setHeartbeatData, getGmailEnabled } from '../storage'
77
import deepEqual from 'deep-equal'
88

9+
export function setupMessageListener(client: AWClient) {
10+
browser.runtime.onMessage.addListener(
11+
async (message: any) => {
12+
const enabled = await getEnabled();
13+
const gmailEnabled = await getGmailEnabled();
14+
if (!enabled || !gmailEnabled) return;
15+
16+
if (message.type === 'AW_GMAIL_HEARTBEAT') {
17+
const tab = await getActiveWindowTab()
18+
if (!tab || !tab.url || !tab.title) return;
19+
if (!tab.url.includes('mail.google.com')) return;
20+
const tabs = await getTabs();
21+
22+
const data: IEvent['data'] = {
23+
url: tab.url,
24+
title: tab.title,
25+
audible: tab.audible ?? false,
26+
incognito: tab.incognito,
27+
tabCount: tabs.length,
28+
...message.data,
29+
};
30+
await performHeartbeat(client, data);
31+
}
32+
},
33+
)
34+
}
35+
36+
async function performHeartbeat(
37+
client: AWClient,
38+
data: IEvent['data'],
39+
options: { finalizeOnly?: boolean } = {}
40+
) {
41+
const bucketId = await getBucketId()
42+
const now = new Date()
43+
const previousData = await getHeartbeatData()
44+
if (previousData && !deepEqual(previousData, data)) {
45+
console.debug('[Background] Activity changed, finalizing previous session', previousData)
46+
await sendHeartbeat(
47+
client,
48+
bucketId,
49+
new Date(now.getTime() - 1),
50+
previousData,
51+
config.heartbeat.intervalInSeconds + 20,
52+
).catch(() => {})
53+
}
54+
55+
if (options.finalizeOnly) {
56+
return;
57+
}
58+
59+
console.debug('[Background] Sending heartbeat', data)
60+
await sendHeartbeat(
61+
client,
62+
bucketId,
63+
now,
64+
data,
65+
config.heartbeat.intervalInSeconds + 20,
66+
).catch((err: unknown) => {
67+
console.error('[Background] Failed to send heartbeat:', err);
68+
})
69+
70+
await setHeartbeatData(data)
71+
}
72+
973
async function heartbeat(
1074
client: AWClient,
1175
tab: browser.Tabs.Tab | undefined,
@@ -27,34 +91,23 @@ async function heartbeat(
2791
return
2892
}
2993

30-
const now = new Date()
3194
const data: IEvent['data'] = {
3295
url: tab.url,
3396
title: tab.title,
3497
audible: tab.audible ?? false,
3598
incognito: tab.incognito,
3699
tabCount: tabCount,
37100
}
38-
const previousData = await getHeartbeatData()
39-
if (previousData && !deepEqual(previousData, data)) {
40-
console.debug('Sending heartbeat for previous data', previousData)
41-
await sendHeartbeat(
42-
client,
43-
await getBucketId(),
44-
new Date(now.getTime() - 1),
45-
previousData,
46-
config.heartbeat.intervalInSeconds + 20,
47-
)
101+
102+
const gmailEnabled = await getGmailEnabled();
103+
if (gmailEnabled && tab.url.includes('mail.google.com')) {
104+
// Sharp cut: finalize the previous activity (e.g. if we came from Google Search)
105+
// but don't start the 'Generic' Gmail event. Gmail.ts will do that with metadata.
106+
await performHeartbeat(client, data, { finalizeOnly: true });
107+
return;
48108
}
49-
console.debug('Sending heartbeat', data)
50-
await sendHeartbeat(
51-
client,
52-
await getBucketId(),
53-
now,
54-
data,
55-
config.heartbeat.intervalInSeconds + 20,
56-
)
57-
await setHeartbeatData(data)
109+
110+
await performHeartbeat(client, data);
58111
}
59112

60113
export const sendInitialHeartbeat = async (client: AWClient) => {
@@ -76,9 +129,9 @@ export const heartbeatAlarmListener =
76129

77130
export const tabActivatedListener =
78131
(client: AWClient) =>
79-
async (activeInfo: browser.Tabs.OnActivatedActiveInfoType) => {
80-
const tab = await getTab(activeInfo.tabId)
81-
const tabs = await getTabs()
82-
console.debug('Sending heartbeat for tab activation', tab)
83-
await heartbeat(client, tab, tabs.length)
84-
}
132+
async (activeInfo: browser.Tabs.OnActivatedActiveInfoType) => {
133+
const tab = await getTab(activeInfo.tabId)
134+
const tabs = await getTabs()
135+
console.debug('Sending heartbeat for tab activation', tab)
136+
await heartbeat(client, tab, tabs.length)
137+
}

src/background/main.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
heartbeatAlarmListener,
55
sendInitialHeartbeat,
66
tabActivatedListener,
7+
setupMessageListener,
78
} from './heartbeat'
89
import { getClient, detectHostname } from './client'
910
import {
@@ -15,6 +16,7 @@ import {
1516
setHostname,
1617
waitForEnabled,
1718
} from '../storage'
19+
import { AWClient } from 'aw-client'
1820

1921
async function getIsConsentRequired() {
2022
if (!config.requireConsent) return false
@@ -24,7 +26,7 @@ async function getIsConsentRequired() {
2426
.catch(() => true)
2527
}
2628

27-
async function autodetectHostname() {
29+
async function autodetectHostname(client: AWClient) {
2830
const hostname = await getHostname()
2931
if (hostname === undefined) {
3032
const detectedHostname = await detectHostname(client)
@@ -57,13 +59,17 @@ browser.runtime.onInstalled.addListener(async () => {
5759
})
5860
}
5961

60-
await autodetectHostname()
62+
await autodetectHostname(client)
6163
})
6264

6365
console.debug('Creating alarms and tab listeners')
6466
browser.alarms.create(config.heartbeat.alarmName, {
6567
periodInMinutes: Math.floor(config.heartbeat.intervalInSeconds / 60),
6668
})
69+
70+
// Set up Gmail message listener (other watchers will be added later)
71+
setupMessageListener(client)
72+
6773
browser.alarms.onAlarm.addListener(heartbeatAlarmListener(client))
6874
browser.tabs.onActivated.addListener(tabActivatedListener(client))
6975

src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const config = {
55
alarmName: 'heartbeat',
66
intervalInSeconds: 60,
77
},
8+
baseURL: 'http://127.0.0.1:5666', // for testing locally, will be removed once PR ready
89
}
910

1011
export default config

src/content/gmail.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import browser from 'webextension-polyfill'
2+
import deepEqual from 'deep-equal'
3+
import config from '../config'
4+
5+
let lastData: any | null = null
6+
7+
if (window.top === window.self) {
8+
9+
function isExtensionValid() {
10+
return typeof browser !== 'undefined' && !!browser.storage && !!browser.runtime?.id
11+
}
12+
13+
14+
function getComposeMetadata(form: HTMLElement) {
15+
const getRecipients = (name: string) =>
16+
Array.from(
17+
form.querySelectorAll(`div[name="${name}"] [data-hovercard-id]`),
18+
).map((el) => el.getAttribute('data-hovercard-id'))
19+
.filter(Boolean) as string[]
20+
21+
return {
22+
gmail_activity: 'composing_email',
23+
subject: (form.querySelector('input[name="subjectbox"]') as HTMLInputElement)?.value || '',
24+
to: getRecipients('to'),
25+
cc: getRecipients('cc'),
26+
bcc: getRecipients('bcc'),
27+
}
28+
}
29+
30+
function sendGmailHeartbeat() {
31+
if (!isExtensionValid()) {
32+
stopTracking()
33+
return
34+
}
35+
if (document.visibilityState === 'hidden') {
36+
return
37+
}
38+
39+
const hash = window.location.hash
40+
// for simplity in MVP:
41+
// - if many emails forms are open, we only track the first one
42+
const form = document.querySelector('div[role="dialog"] form') as HTMLElement | null
43+
44+
let activity = 'reading_inbox'
45+
let meta: any = { gmail_activity: activity }
46+
47+
if (form) {
48+
activity = 'composing_email'
49+
meta = getComposeMetadata(form)
50+
} else if (
51+
hash.includes('inbox/') ||
52+
hash.includes('sent/') ||
53+
hash.includes('all/')
54+
) {
55+
/**
56+
* NOTE on Fragility: The selectors below (span.gD, .gE, h2.hP) are internal
57+
* Gmail class names. These are not part of a stable API and may change
58+
* during Gmail frontend updates. High-fidelity tracking may require
59+
* maintenance if these selectors break.
60+
*/
61+
const fromEl = document.querySelector('span.gD')
62+
const from =
63+
fromEl?.getAttribute('email') ||
64+
fromEl?.getAttribute('data-hovercard-id') ||
65+
(fromEl as HTMLElement)?.innerText ||
66+
''
67+
const to = Array.from(
68+
document.querySelectorAll('.gE [email], .gE [data-hovercard-id]'),
69+
)
70+
.map(
71+
(el) => el.getAttribute('email') || el.getAttribute('data-hovercard-id'),
72+
)
73+
.filter((e) => e && e !== from) as string[]
74+
75+
activity = 'reading_email'
76+
meta = {
77+
gmail_activity: activity,
78+
subject: (document.querySelector('h2.hP') as HTMLElement)?.innerText || '',
79+
from,
80+
to,
81+
}
82+
}
83+
84+
if (!deepEqual(lastData, meta)) {
85+
lastData = meta;
86+
if (!isExtensionValid()) return;
87+
browser.runtime.sendMessage({
88+
type: 'AW_GMAIL_HEARTBEAT',
89+
data: meta
90+
}).catch(() => {})
91+
}
92+
}
93+
94+
let detectIntervalId: ReturnType<typeof setInterval> | null = null
95+
let pulseIntervalId: ReturnType<typeof setInterval> | null = null
96+
97+
function startTracking() {
98+
if (detectIntervalId !== null) {
99+
return
100+
}
101+
102+
detectIntervalId = setInterval(sendGmailHeartbeat, 1000)
103+
pulseIntervalId = setInterval(() => {
104+
if (!isExtensionValid()) {
105+
stopTracking()
106+
return
107+
}
108+
if (lastData && document.visibilityState === 'visible') {
109+
try {
110+
browser.runtime.sendMessage({
111+
type: 'AW_GMAIL_HEARTBEAT',
112+
data: lastData
113+
}).catch(() => {})
114+
} catch (err) {
115+
// Extension context invalidated
116+
}
117+
}
118+
}, config.heartbeat.intervalInSeconds * 1000)
119+
120+
sendGmailHeartbeat()
121+
}
122+
123+
async function refreshTracking() {
124+
if (!isExtensionValid()) {
125+
return
126+
}
127+
try {
128+
const settings = await browser.storage.local.get(['gmailEnabled', 'enabled'])
129+
const shouldTrack = Boolean(settings.gmailEnabled && settings.enabled)
130+
131+
if (shouldTrack) {
132+
startTracking()
133+
} else {
134+
stopTracking()
135+
}
136+
} catch (err) {
137+
console.error('[Gmail Content] Failed to refresh tracking state', err)
138+
}
139+
}
140+
141+
function stopTracking() {
142+
if (detectIntervalId !== null) {
143+
clearInterval(detectIntervalId)
144+
detectIntervalId = null
145+
}
146+
if (pulseIntervalId !== null) {
147+
clearInterval(pulseIntervalId)
148+
pulseIntervalId = null
149+
}
150+
lastData = null
151+
}
152+
153+
if (isExtensionValid()) {
154+
refreshTracking()
155+
156+
browser.storage.onChanged.addListener((changes) => {
157+
if ('gmailEnabled' in changes || 'enabled' in changes) {
158+
refreshTracking()
159+
}
160+
})
161+
}
162+
163+
} // if (window.top === window.self)

src/manifest.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,15 @@
6464
"gecko": {
6565
"id": "{ef87d84c-2127-493f-b952-5b4e744245bc}"
6666
}
67-
}
67+
},
68+
"content_scripts": [
69+
{
70+
"matches": [
71+
"*://mail.google.com/*"
72+
],
73+
"js": [
74+
"src/content/gmail.ts"
75+
]
76+
}
77+
]
6878
}

0 commit comments

Comments
 (0)