Skip to content

Commit e9efbd8

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 e9efbd8

File tree

8 files changed

+201
-4
lines changed

8 files changed

+201
-4
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/heartbeat.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,26 @@ 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 bucketId = await getBucketId()
18+
await sendHeartbeat(client, bucketId, new Date(), message.data, config.heartbeat.intervalInSeconds + 20).catch((err: unknown) => {
19+
console.error('[Background] Failed to send Gmail heartbeat:', err);
20+
})
21+
}
22+
},
23+
)
24+
}
25+
926
async function heartbeat(
1027
client: AWClient,
1128
tab: browser.Tabs.Tab | undefined,
@@ -27,6 +44,11 @@ async function heartbeat(
2744
return
2845
}
2946

47+
const gmailEnabled = await getGmailEnabled()
48+
if (gmailEnabled && tab.url.includes('mail.google.com')) {
49+
return
50+
}
51+
3052
const now = new Date()
3153
const data: IEvent['data'] = {
3254
url: tab.url,

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/content/gmail.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import browser from 'webextension-polyfill'
2+
3+
4+
function getComposeMetadata(form: HTMLElement) {
5+
const getRecipients = (name: string) =>
6+
Array.from(
7+
form.querySelectorAll(`div[name="${name}"] [data-hovercard-id]`),
8+
).map((el) => el.getAttribute('data-hovercard-id'))
9+
.filter(Boolean) as string[]
10+
11+
return {
12+
subject: (form.querySelector('input[name="subjectbox"]') as HTMLInputElement)?.value || '',
13+
to: getRecipients('to'),
14+
cc: getRecipients('cc'),
15+
bcc: getRecipients('bcc'),
16+
}
17+
}
18+
19+
let lastData: string | null = null
20+
let lastSentTime = 0
21+
22+
function sendGmailHeartbeat() {
23+
if (document.visibilityState === 'hidden') {
24+
return
25+
}
26+
27+
const hash = window.location.hash
28+
const form = document.querySelector('div[role="dialog"] form') as HTMLElement | null
29+
30+
let activity = 'reading_inbox'
31+
let meta: any = {}
32+
33+
if (form) {
34+
// for simplity in MVP:
35+
// - if many emails forms are open, we only track the first one
36+
// - if subject, to, cc, bcc changes when composing the email, we send a new heartbeat
37+
activity = 'composing_email'
38+
meta = getComposeMetadata(form)
39+
} else if (
40+
hash.includes('inbox/') ||
41+
hash.includes('sent/') ||
42+
hash.includes('all/')
43+
) {
44+
/**
45+
* NOTE on Fragility: The selectors below (span.gD, .gE, h2.hP) are internal
46+
* Gmail class names. These are not part of a stable API and may change
47+
* during Gmail frontend updates. High-fidelity tracking may require
48+
* maintenance if these selectors break.
49+
*/
50+
const fromEl = document.querySelector('span.gD')
51+
const from =
52+
fromEl?.getAttribute('email') ||
53+
fromEl?.getAttribute('data-hovercard-id') ||
54+
(fromEl as HTMLElement)?.innerText ||
55+
''
56+
const to = Array.from(
57+
document.querySelectorAll('.gE [email], .gE [data-hovercard-id]'),
58+
)
59+
.map(
60+
(el) => el.getAttribute('email') || el.getAttribute('data-hovercard-id'),
61+
)
62+
.filter((e) => e && e !== from) as string[]
63+
64+
activity = 'reading_email'
65+
meta = {
66+
subject: (document.querySelector('h2.hP') as HTMLElement)?.innerText || '',
67+
from,
68+
to,
69+
}
70+
}
71+
72+
const data = {
73+
gmail_activity: activity,
74+
...meta,
75+
url: window.location.href,
76+
title: document.title,
77+
}
78+
79+
const dataString = JSON.stringify(data)
80+
const now = Date.now()
81+
if (lastData === dataString && now - lastSentTime < 30000) {
82+
return
83+
}
84+
lastData = dataString
85+
lastSentTime = now
86+
87+
browser.runtime.sendMessage({ type: 'AW_GMAIL_HEARTBEAT', data }).catch(() => {})
88+
}
89+
90+
let intervalId: ReturnType<typeof setInterval> | null = null
91+
92+
function startTracking() {
93+
if (intervalId !== null) {
94+
return
95+
}
96+
sendGmailHeartbeat()
97+
sendGmailHeartbeat()
98+
intervalId = setInterval(sendGmailHeartbeat, 1000)
99+
}
100+
101+
function stopTracking() {
102+
if (intervalId === null) {
103+
return
104+
}
105+
clearInterval(intervalId)
106+
intervalId = null
107+
}
108+
109+
browser.storage.local.get('gmailEnabled').then((settings) => {
110+
if (settings.gmailEnabled) {
111+
startTracking()
112+
}
113+
})
114+
115+
browser.storage.onChanged.addListener((changes) => {
116+
if ('gmailEnabled' in changes) {
117+
if (changes.gmailEnabled.newValue) {
118+
startTracking()
119+
} else {
120+
stopTracking()
121+
}
122+
}
123+
})

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
}

src/settings/index.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@
5151
/>
5252
</div>
5353

54+
<div>
55+
<label for="gmailEnabled">
56+
<strong>Extract Gmail details:</strong>
57+
</label>
58+
<input type="checkbox" id="gmailEnabled" name="gmailEnabled" />
59+
</div>
60+
5461
<div>
5562
<button type="submit">Save</button>
5663
</div>

src/settings/main.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
setBrowserName,
55
getHostname,
66
setHostname,
7+
getGmailEnabled,
8+
setGmailEnabled,
79
} from '../storage'
810
import { detectBrowser } from '../background/helpers'
911

@@ -34,6 +36,10 @@ async function saveOptions(e: SubmitEvent): Promise<void> {
3436

3537
const hostname = hostnameInput.value
3638

39+
const gmailEnabledCheckbox =
40+
document.querySelector<HTMLInputElement>('#gmailEnabled')
41+
const gmailEnabled = gmailEnabledCheckbox?.checked || false
42+
3743
const form = e.target as HTMLFormElement
3844
const button = form.querySelector<HTMLButtonElement>('button')
3945
if (!button) return
@@ -44,6 +50,7 @@ async function saveOptions(e: SubmitEvent): Promise<void> {
4450
try {
4551
await setBrowserName(selectedBrowser)
4652
await setHostname(hostname)
53+
await setGmailEnabled(gmailEnabled)
4754
await reloadExtension()
4855
button.textContent = 'Save'
4956
button.classList.add('accept')
@@ -95,6 +102,13 @@ async function restoreOptions(): Promise<void> {
95102
if (hostname !== undefined) {
96103
hostnameInput.value = hostname
97104
}
105+
106+
const gmailEnabled = await getGmailEnabled()
107+
const gmailEnabledCheckbox =
108+
document.querySelector<HTMLInputElement>('#gmailEnabled')
109+
if (gmailEnabledCheckbox) {
110+
gmailEnabledCheckbox.checked = gmailEnabled
111+
}
98112
} catch (error) {
99113
console.error('Failed to restore options:', error)
100114
}

src/storage.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,10 @@ export const getHostname = (): Promise<Hostname | undefined> =>
101101
.then((data: StorageData) => data.hostname as string | undefined)
102102
export const setHostname = (hostname: Hostname) =>
103103
browser.storage.local.set({ hostname })
104+
105+
type GmailEnabled = boolean
106+
export const getGmailEnabled = (): Promise<GmailEnabled> =>
107+
browser.storage.local.get('gmailEnabled').then((_) => Boolean(_.gmailEnabled))
108+
export const setGmailEnabled = (gmailEnabled: GmailEnabled) =>
109+
browser.storage.local.set({ gmailEnabled })
110+

0 commit comments

Comments
 (0)