Skip to content

Commit c598b86

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 c598b86

8 files changed

Lines changed: 302 additions & 30 deletions

File tree

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: 83 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,76 @@ 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, sender: browser.Runtime.MessageSender) => {
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 = sender.tab;
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+
if (previousData) {
57+
await browser.storage.local.remove('heartbeatData');
58+
}
59+
return;
60+
}
61+
62+
console.debug('[Background] Sending heartbeat', data)
63+
await sendHeartbeat(
64+
client,
65+
bucketId,
66+
now,
67+
data,
68+
config.heartbeat.intervalInSeconds + 20,
69+
).catch((err: unknown) => {
70+
console.error('[Background] Failed to send heartbeat:', err);
71+
})
72+
73+
await setHeartbeatData(data)
74+
}
75+
976
async function heartbeat(
1077
client: AWClient,
1178
tab: browser.Tabs.Tab | undefined,
@@ -27,34 +94,23 @@ async function heartbeat(
2794
return
2895
}
2996

30-
const now = new Date()
3197
const data: IEvent['data'] = {
3298
url: tab.url,
3399
title: tab.title,
34100
audible: tab.audible ?? false,
35101
incognito: tab.incognito,
36102
tabCount: tabCount,
37103
}
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-
)
104+
105+
const gmailEnabled = await getGmailEnabled();
106+
if (gmailEnabled && tab.url.includes('mail.google.com')) {
107+
// Sharp cut: finalize the previous activity (e.g. if we came from Google Search)
108+
// but don't start the 'Generic' Gmail event. Gmail.ts will do that with metadata.
109+
await performHeartbeat(client, data, { finalizeOnly: true });
110+
return;
48111
}
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)
112+
113+
await performHeartbeat(client, data);
58114
}
59115

60116
export const sendInitialHeartbeat = async (client: AWClient) => {
@@ -76,9 +132,9 @@ export const heartbeatAlarmListener =
76132

77133
export const tabActivatedListener =
78134
(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-
}
135+
async (activeInfo: browser.Tabs.OnActivatedActiveInfoType) => {
136+
const tab = await getTab(activeInfo.tabId)
137+
const tabs = await getTabs()
138+
console.debug('Sending heartbeat for tab activation', tab)
139+
await heartbeat(client, tab, tabs.length)
140+
}

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

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/popup/index.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@
4848
</td>
4949
</tr>
5050

51+
<tr>
52+
<!-- TODO: we should move this to another page -->
53+
<!-- if the number of email providers or websites that we want to extract data from increases -->
54+
<th align="right">Gmail details:</th>
55+
<td>
56+
<input type="checkbox" id="status-gmail-enabled-checkbox" />
57+
</td>
58+
</tr>
59+
5160
<tr>
5261
<th align="right">Connected:</th>
5362
<td id="status-connected-icon">

0 commit comments

Comments
 (0)