Skip to content

Commit d3f63b9

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 d3f63b9

File tree

8 files changed

+156
-4
lines changed

8 files changed

+156
-4
lines changed

build.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
make build-chrome && \
2+
mkdir -p artifacts/chrome && \
3+
unzip -o artifacts/chrome.zip -d artifacts/chrome
4+
5+
make build-firefox && \
6+
mkdir -p artifacts/firefox && \
7+
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, 10).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: 7 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 {
@@ -24,7 +25,7 @@ async function getIsConsentRequired() {
2425
.catch(() => true)
2526
}
2627

27-
async function autodetectHostname() {
28+
async function autodetectHostname(client: any) {
2829
const hostname = await getHostname()
2930
if (hostname === undefined) {
3031
const detectedHostname = await detectHostname(client)
@@ -57,13 +58,17 @@ browser.runtime.onInstalled.addListener(async () => {
5758
})
5859
}
5960

60-
await autodetectHostname()
61+
await autodetectHostname(client)
6162
})
6263

6364
console.debug('Creating alarms and tab listeners')
6465
browser.alarms.create(config.heartbeat.alarmName, {
6566
periodInMinutes: Math.floor(config.heartbeat.intervalInSeconds / 60),
6667
})
68+
69+
// Set up Gmail message listener (other watchers will be added later)
70+
setupMessageListener(client)
71+
6772
browser.alarms.onAlarm.addListener(heartbeatAlarmListener(client))
6873
browser.tabs.onActivated.addListener(tabActivatedListener(client))
6974

src/content/gmail.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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+
function sendGmailHeartbeat() {
20+
if (document.visibilityState === 'hidden') {
21+
return
22+
}
23+
24+
const hash = window.location.hash
25+
const form = document.querySelector('div[role="dialog"] form') as HTMLElement | null
26+
27+
let activity = 'reading_inbox'
28+
let meta: any = {}
29+
30+
if (form) {
31+
// for simplity in MVP:
32+
// - if many emails forms are open, we only track the first one
33+
// - if subject, to, cc, bcc changes when composing the email, we send a new heartbeat
34+
activity = 'composing_email'
35+
meta = getComposeMetadata(form)
36+
} else if (
37+
hash.includes('inbox/') ||
38+
hash.includes('sent/') ||
39+
hash.includes('all/')
40+
) {
41+
const fromEl = document.querySelector('span.gD')
42+
const from =
43+
fromEl?.getAttribute('email') ||
44+
fromEl?.getAttribute('data-hovercard-id') ||
45+
(fromEl as HTMLElement)?.innerText ||
46+
''
47+
const to = Array.from(
48+
document.querySelectorAll('.gE [email], .gE [data-hovercard-id]'),
49+
)
50+
.map(
51+
(el) => el.getAttribute('email') || el.getAttribute('data-hovercard-id'),
52+
)
53+
.filter((e) => e && e !== from) as string[]
54+
55+
activity = 'reading_email'
56+
meta = {
57+
subject: (document.querySelector('h2.hP') as HTMLElement)?.innerText || '',
58+
from,
59+
to,
60+
}
61+
}
62+
63+
const data = {
64+
gmail_activity: activity,
65+
...meta,
66+
url: window.location.href,
67+
title: document.title,
68+
}
69+
70+
browser.runtime.sendMessage({ type: 'AW_GMAIL_HEARTBEAT', data }).catch(() => {})
71+
}
72+
73+
browser.storage.local.get('gmailEnabled').then((settings) => {
74+
if (!settings.gmailEnabled) {
75+
return
76+
}
77+
78+
setInterval(sendGmailHeartbeat, 1000)
79+
sendGmailHeartbeat()
80+
})

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)