Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ A browser extension for multi-account Gmail™ notifications. Get a badge counte
- **Quick actions** — mark as read, archive, move to spam, delete, reply, or open in Gmail™, all without leaving the extension
- **Notification sounds** — optional audio alert on new mail; choose the built-in sound or upload your own (WAV/MP3/OGG, max 500 KB / 5 s) with adjustable volume
- **Themes** — light, dark, or auto (follows system preference)
- **Privacy controls** — optionally block external images in email previews (including tracking pixels); disabled by default so images load normally
- **Configurable** — poll interval (1–30 min), max messages per page (5–50), popup size, keyboard shortcuts, and more
- **Limits** — up to 100 unread messages are fetched per inbox per poll; emails larger than 5 MB cannot be previewed inline (open in Gmail™ instead)

Expand Down
7 changes: 4 additions & 3 deletions src/background/accounts.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,16 @@ export async function removeAccount(accountId) {
return false;
}
const tokens = await getTokens(accountId);
let revokeOk = true;
if (tokens?.refreshToken) {
await revokeToken(tokens.refreshToken);
revokeOk = await revokeToken(tokens.refreshToken);
} else if (tokens?.accessToken) {
await revokeToken(tokens.accessToken);
revokeOk = await revokeToken(tokens.accessToken);
}
const remaining = accounts.filter((a) => a.id !== accountId);
await saveAccounts(remaining);
await clearAccountData(accountId);
return true;
return { ok: true, revokeOk };
}

export async function updateAccount(accountId, patch) {
Expand Down
58 changes: 40 additions & 18 deletions src/background/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
TOKEN_ENDPOINT,
USERINFO_ENDPOINT,
} from '../shared/constants.js';
import { clearPkceState, loadPkceState, savePkceState } from '../shared/storage.js';

const api = typeof browser !== 'undefined' ? browser : globalThis.chrome;

Expand Down Expand Up @@ -175,16 +176,20 @@ export async function refreshAccessToken({ refreshToken, clientId, clientSecret

export async function revokeToken(token) {
if (!token) {
return;
return true;
}
const body = new URLSearchParams({ token });
await fetch(REVOKE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
}).catch(() => {
// Best-effort revocation — offline or already revoked are non-fatal.
});
try {
const response = await fetch(REVOKE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
return response.ok;
} catch {
// Network failure — token may still be valid on Google's side.
return false;
}
}

export async function fetchUserInfo(accessToken) {
Expand Down Expand Up @@ -221,18 +226,35 @@ export async function launchOAuth({
const { verifier, challenge } = await generatePkcePair();
const state = randomString(32);
const redirectUri = getRedirectUri();
const authUrl = buildAuthUrl({
clientId,
redirectUri,
codeChallenge: challenge,
state,
loginHint,
});

const redirectUrl = await launchAuthWindow(authUrl, redirectUri);
// Persist verifier and state so a service-worker restart mid-flow can still
// verify the redirect and complete the token exchange.
await savePkceState({ verifier, state });

let redirectUrl;
try {
const authUrl = buildAuthUrl({
clientId,
redirectUri,
codeChallenge: challenge,
state,
loginHint,
});
redirectUrl = await launchAuthWindow(authUrl, redirectUri);
} catch (err) {
await clearPkceState();
throw err;
}

// After a worker restart the in-memory verifier/state would be lost, so
// always read back from storage as the authoritative source.
const persisted = await loadPkceState();
const resolvedVerifier = persisted?.verifier ?? verifier;
const resolvedState = persisted?.state ?? state;
await clearPkceState();

const { code, state: returnedState } = extractAuthResult(redirectUrl);
if (returnedState !== state) {
if (returnedState !== resolvedState) {
throw new Error('OAuth state mismatch — possible CSRF.');
}
if (!code) {
Expand All @@ -241,7 +263,7 @@ export async function launchOAuth({

const tokens = await exchangeCodeForTokens({
code,
codeVerifier: verifier,
codeVerifier: resolvedVerifier,
redirectUri,
clientId,
clientSecret,
Expand Down
14 changes: 14 additions & 0 deletions src/background/badge.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,17 @@ export async function updateBadge(totalUnread, { color = '#d93025' } = {}) {
export async function clearBadge() {
await updateBadge(0);
}

export async function showAuthErrorBadge() {
try {
await api.action.setBadgeText({ text: '!' });
if (api.action.setBadgeBackgroundColor) {
await api.action.setBadgeBackgroundColor({ color: '#e37400' });
}
if (api.action.setBadgeTextColor) {
await api.action.setBadgeTextColor({ color: '#ffffff' });
}
} catch (err) {
console.warn('Failed to set auth error badge:', err);
}
}
47 changes: 28 additions & 19 deletions src/background/service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
reorderAccounts,
updateAccount,
} from './accounts.js';
import { clearBadge, updateBadge } from './badge.js';
import { clearBadge, showAuthErrorBadge, updateBadge } from './badge.js';
import {
registerNotificationButtonHandler,
registerNotificationClickHandler,
Expand Down Expand Up @@ -61,6 +61,21 @@ function setAccountState(accountId, patch) {
return next;
}

// Updates the badge to the total unread count, or to '!' (amber) if any
// account needs re-authorization and there are no unread messages.
async function updateBadgeOrWarn(total) {
if (total > 0) {
await updateBadge(total);
return;
}
const needsReauth = Array.from(accountState.values()).some((s) => s.needsReauth);
if (needsReauth) {
await showAuthErrorBadge();
} else {
await clearBadge();
}
}

async function pollAccount(account, { isInitial = false } = {}) {
try {
const token = await getValidAccessToken(account.id);
Expand Down Expand Up @@ -146,11 +161,7 @@ async function pollAllAccounts({ isInitial = false } = {}) {
accountState.delete(id);
}
}
if (total > 0) {
await updateBadge(total);
} else {
await clearBadge();
}
await updateBadgeOrWarn(total);
// Persist so the popup sees correct state immediately after SW restart.
await savePersistedAccountState(Object.fromEntries(accountState));
return total;
Expand Down Expand Up @@ -213,10 +224,16 @@ async function handleMessage(msg, _sender) {
return { account: acc };
}
case 'geething.removeAccount': {
const ok = await removeAccount(msg.accountId);
const { ok, revokeOk } = await removeAccount(msg.accountId);
accountState.delete(msg.accountId);
await pollAllAccounts();
return { ok };
return {
ok,
revokeWarning:
revokeOk === false
? 'Token revocation failed — you may want to revoke access manually from your Google Account security settings.'
: null,
};
}
case 'geething.updateAccount': {
const acc = await updateAccount(msg.accountId, msg.patch || {});
Expand Down Expand Up @@ -276,11 +293,7 @@ async function handleMessage(msg, _sender) {
(sum, s) => sum + (s.unreadCount || 0),
0,
);
if (total > 0) {
await updateBadge(total);
} else {
await clearBadge();
}
await updateBadgeOrWarn(total);
return { ok: true };
}
case 'geething.getLabels': {
Expand Down Expand Up @@ -382,7 +395,7 @@ async function performAction({ accountId, messageId, action }) {
(sum, s) => sum + (s.unreadCount || 0),
0,
);
await updateBadge(total);
await updateBadgeOrWarn(total);
}
}
return { ok: true };
Expand Down Expand Up @@ -439,11 +452,7 @@ function attachListeners() {
(sum, s) => sum + (s.unreadCount || 0),
0,
);
if (total > 0) {
await updateBadge(total);
} else {
await clearBadge();
}
await updateBadgeOrWarn(total);
});

if (api.storage?.onChanged) {
Expand Down
5 changes: 4 additions & 1 deletion src/options/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -367,8 +367,11 @@ async function renderAccountRow(account) {
}
removeBtn.disabled = true;
try {
await sendMessage({ type: 'geething.removeAccount', accountId: account.id });
const result = await sendMessage({ type: 'geething.removeAccount', accountId: account.id });
await loadState();
if (result?.revokeWarning) {
showStatus(result.revokeWarning);
}
} catch (err) {
showStatus(err.message || String(err));
removeBtn.disabled = false;
Expand Down
1 change: 1 addition & 0 deletions src/shared/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const STORAGE_KEYS = Object.freeze({
SEEN_MESSAGES: 'seenMessages',
ACCOUNT_STATE: 'accountState',
CUSTOM_SOUND: 'customSound',
PKCE_STATE: 'pkceState',
});

export const ALARM_NAMES = Object.freeze({
Expand Down
13 changes: 13 additions & 0 deletions src/shared/storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ export async function clearCustomSound() {
await localArea().remove(STORAGE_KEYS.CUSTOM_SOUND);
}

export async function savePkceState(pkceState) {
await localArea().set({ [STORAGE_KEYS.PKCE_STATE]: pkceState });
}

export async function loadPkceState() {
const result = await localArea().get(STORAGE_KEYS.PKCE_STATE);
return result[STORAGE_KEYS.PKCE_STATE] || null;
}

export async function clearPkceState() {
await localArea().remove(STORAGE_KEYS.PKCE_STATE);
}

export async function clearAccountData(accountId) {
await deleteTokens(accountId);
const result = await localArea().get(STORAGE_KEYS.SEEN_MESSAGES);
Expand Down
7 changes: 4 additions & 3 deletions tests/unit/accounts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ vi.mock('../../src/background/auth.js', () => {
return {
launchOAuth: vi.fn(),
refreshAccessToken: vi.fn(),
revokeToken: vi.fn(async () => {}),
revokeToken: vi.fn(async () => true),
};
});

Expand Down Expand Up @@ -63,8 +63,9 @@ describe('accounts', () => {
userInfo: { email: 'c@example.com' },
});
const acc = await addAccount();
const ok = await removeAccount(acc.id);
expect(ok).toBe(true);
const result = await removeAccount(acc.id);
expect(result.ok).toBe(true);
expect(result.revokeOk).toBe(true);
expect(authMod.revokeToken).toHaveBeenCalledWith('rr');
expect(await getAccounts()).toHaveLength(0);
expect(await getTokens(acc.id)).toBeNull();
Expand Down
21 changes: 19 additions & 2 deletions tests/unit/service-worker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ vi.mock('../../src/background/sound.js', () => ({
vi.mock('../../src/background/badge.js', () => ({
updateBadge: vi.fn(),
clearBadge: vi.fn(),
showAuthErrorBadge: vi.fn(),
}));

vi.mock('../../src/shared/storage.js', () => ({
Expand All @@ -65,6 +66,9 @@ vi.mock('../../src/shared/storage.js', () => ({
getSettings: vi.fn().mockResolvedValue({ notificationsEnabled: true, pollIntervalMinutes: 2 }),
saveCustomSound: vi.fn(),
clearCustomSound: vi.fn(),
savePkceState: vi.fn(),
loadPkceState: vi.fn().mockResolvedValue(null),
clearPkceState: vi.fn(),
}));

import {
Expand Down Expand Up @@ -94,7 +98,7 @@ import {
showGroupedMailNotification,
} from '../../src/background/notifications.js';
import { playNotificationSound } from '../../src/background/sound.js';
import { updateBadge, clearBadge } from '../../src/background/badge.js';
import { updateBadge, clearBadge, showAuthErrorBadge } from '../../src/background/badge.js';
import {
getPersistedAccountState,
savePersistedAccountState,
Expand Down Expand Up @@ -148,7 +152,7 @@ beforeEach(() => {
getAccounts.mockResolvedValue([]);
getAccountById.mockResolvedValue(null);
getValidAccessToken.mockResolvedValue('mock-token');
removeAccount.mockResolvedValue(true);
removeAccount.mockResolvedValue({ ok: true, revokeOk: true });
updateAccount.mockResolvedValue(ACCOUNT_A);
reorderAccounts.mockResolvedValue([]);
fetchLabels.mockResolvedValue([]);
Expand All @@ -172,6 +176,7 @@ beforeEach(() => {
playNotificationSound.mockReturnValue(undefined);
updateBadge.mockResolvedValue(undefined);
clearBadge.mockResolvedValue(undefined);
showAuthErrorBadge.mockResolvedValue(undefined);
});

// ── pollAllAccounts ───────────────────────────────────────────────────────────
Expand All @@ -185,6 +190,18 @@ describe('pollAllAccounts', () => {
expect(savePersistedAccountState).toHaveBeenCalled();
});

it('shows amber ! badge when an account needs re-auth and total unread is 0', async () => {
getAccounts.mockResolvedValue([ACCOUNT_A]);
fetchUnreadMessageIds.mockResolvedValue([]);
// Simulate a prior poll having set needsReauth.
accountState.set('acc-a', { needsReauth: true, unreadCount: 0, messages: [] });

await pollAllAccounts({ isInitial: true });

expect(showAuthErrorBadge).toHaveBeenCalled();
expect(clearBadge).not.toHaveBeenCalled();
});

it('returns total unread count across all accounts and updates badge', async () => {
getAccounts.mockResolvedValue([ACCOUNT_A, ACCOUNT_B]);
getValidAccessToken.mockResolvedValueOnce('token-a').mockResolvedValueOnce('token-b');
Expand Down
Loading