Skip to content

Commit b06c12e

Browse files
committed
Implement Gmail activity tracking with optional metadata extraction (Sender, Subject, Composing state)
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.
1 parent f391889 commit b06c12e

9 files changed

Lines changed: 282 additions & 146 deletions

File tree

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: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,31 @@ 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 {
7+
getEnabled,
8+
getHeartbeatData,
9+
setHeartbeatData,
10+
getGmailEnabled,
11+
} from '../storage'
712
import deepEqual from 'deep-equal'
813

14+
export function setupMessageListener(client: AWClient) {
15+
browser.runtime.onMessage.addListener(
16+
async (message: any) => {
17+
const enabled = await getEnabled()
18+
const gmailEnabled = await getGmailEnabled()
19+
if (!enabled || !gmailEnabled) return
20+
21+
if (message.type === 'AW_GMAIL_HEARTBEAT') {
22+
const bucketId = await getBucketId()
23+
await sendHeartbeat(client, bucketId, new Date(), message.data, 10).catch((err: unknown) => {
24+
console.error('[Background] Failed to send Gmail heartbeat:', err);
25+
})
26+
}
27+
},
28+
)
29+
}
30+
931
async function heartbeat(
1032
client: AWClient,
1133
tab: browser.Tabs.Tab | undefined,
@@ -27,6 +49,11 @@ async function heartbeat(
2749
return
2850
}
2951

52+
const gmailEnabled = await getGmailEnabled()
53+
if (gmailEnabled && tab.url.includes('mail.google.com')) {
54+
return
55+
}
56+
3057
const now = new Date()
3158
const data: IEvent['data'] = {
3259
url: tab.url,
@@ -35,6 +62,7 @@ async function heartbeat(
3562
incognito: tab.incognito,
3663
tabCount: tabCount,
3764
}
65+
3866
const previousData = await getHeartbeatData()
3967
if (previousData && !deepEqual(previousData, data)) {
4068
console.debug('Sending heartbeat for previous data', previousData)

src/background/main.ts

Lines changed: 45 additions & 61 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)
@@ -34,72 +35,55 @@ async function autodetectHostname() {
3435
}
3536
}
3637

37-
/** Init */
38-
console.info('Starting...')
38+
async function start() {
39+
console.info('Starting...')
3940

40-
console.debug('Creating client')
41-
const client = getClient()
41+
const client = getClient()
4242

43-
browser.runtime.onInstalled.addListener(async () => {
44-
const { consent } = await getConsentStatus()
45-
const isConsentRequired = await getIsConsentRequired()
46-
if (!isConsentRequired || consent) {
47-
if (!isConsentRequired) console.info('Consent is not required')
48-
else if (consent) console.info('Consent required but already accepted')
49-
console.debug('Enabling the extension')
50-
await setEnabled(true)
51-
} else {
52-
console.info('Consent is required...opening consent tab')
53-
await setConsentStatus({ consent, required: true })
54-
await browser.tabs.create({
55-
active: true,
56-
url: browser.runtime.getURL('src/consent/index.html'),
57-
})
58-
}
59-
60-
await autodetectHostname()
61-
})
43+
browser.runtime.onInstalled.addListener(async () => {
44+
const { consent } = await getConsentStatus()
45+
const isConsentRequired = await getIsConsentRequired()
46+
if (!isConsentRequired || consent) {
47+
if (!isConsentRequired) console.info('Consent is not required')
48+
else if (consent) console.info('Consent required but already accepted')
49+
console.debug('Enabling the extension')
50+
await setEnabled(true)
51+
} else {
52+
console.info('Consent is required...opening consent tab')
53+
await setConsentStatus({ consent, required: true })
54+
await browser.tabs.create({
55+
active: true,
56+
url: browser.runtime.getURL('src/consent/index.html'),
57+
})
58+
}
6259

63-
console.debug('Creating alarms and tab listeners')
64-
browser.alarms.create(config.heartbeat.alarmName, {
65-
periodInMinutes: Math.floor(config.heartbeat.intervalInSeconds / 60),
66-
})
67-
browser.alarms.onAlarm.addListener(heartbeatAlarmListener(client))
68-
browser.tabs.onActivated.addListener(tabActivatedListener(client))
60+
await autodetectHostname(client)
61+
})
6962

70-
console.debug('Setting base url')
71-
setBaseUrl(client.baseURL)
72-
.then(() =>
73-
console.debug('Waiting for enable before sending initial heartbeat'),
74-
)
75-
.then(waitForEnabled)
76-
.then(() => sendInitialHeartbeat(client))
77-
.then(() => console.info('Started successfully'))
63+
console.debug('Creating alarms and tab listeners')
64+
browser.alarms.create(config.heartbeat.alarmName, {
65+
periodInMinutes: Math.floor(config.heartbeat.intervalInSeconds / 60),
66+
})
67+
68+
// Set up listeners
69+
setupMessageListener(client)
70+
browser.alarms.onAlarm.addListener(heartbeatAlarmListener(client))
71+
browser.tabs.onActivated.addListener(tabActivatedListener(client))
7872

79-
/**
80-
* Keep the service worker alive to prevent Chrome's 5-minute inactivity termination
81-
* This is a workaround for Chrome's behavior of terminating inactive service workers
82-
* https://stackoverflow.com/questions/66618136
83-
*/
84-
if (import.meta.env.VITE_TARGET_BROWSER === 'chrome') {
85-
function setupKeepAlive(): void {
86-
console.debug(
87-
'Setting up keep-alive ping to prevent service worker termination',
73+
console.debug('Setting base url')
74+
setBaseUrl(client.baseURL)
75+
.then(() =>
76+
console.debug('Waiting for enable before sending initial heartbeat'),
8877
)
78+
.then(waitForEnabled)
79+
.then(() => sendInitialHeartbeat(client))
80+
.then(() => console.info('Started successfully'))
8981

90-
setInterval(
91-
() => {
92-
console.debug('Keep-alive ping')
93-
// Force some minimal activity
94-
browser.alarms
95-
.get(config.heartbeat.alarmName)
96-
.then(() => console.debug('Keep-alive ping completed'))
97-
.catch((err) => console.error('Keep-alive ping failed:', err))
98-
},
99-
4 * 60 * 1000,
100-
) // 4 minutes (less than Chrome's ~5 minute timeout)
82+
if (import.meta.env.VITE_TARGET_BROWSER === 'chrome') {
83+
setInterval(() => {
84+
browser.alarms.get(config.heartbeat.alarmName).catch(() => {})
85+
}, 4 * 60 * 1000)
10186
}
102-
103-
// Start the keep-alive mechanism
104-
setupKeepAlive()
10587
}
88+
89+
start()

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: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,15 @@
6464
"gecko": {
6565
"id": "{ef87d84c-2127-493f-b952-5b4e744245bc}"
6666
}
67-
}
68-
}
67+
},
68+
"content_scripts": [
69+
{
70+
"matches": [
71+
"*://mail.google.com/*"
72+
],
73+
"js": [
74+
"src/content/gmail.ts"
75+
]
76+
}
77+
]
78+
}

0 commit comments

Comments
 (0)