commit 922d520f02bf5dfb3c2cd77071f93d98d4421923 Author: Giorgio Gilestro Date: Thu Apr 9 07:56:37 2026 +0100 Initial commit: Outlook Web Inbox Count extension v1.4 Chrome/Firefox extension that displays the total inbox item count next to the Inbox label in Outlook Web. Includes build+publish automation via the Chrome Web Store API (see PUBLISHING.md). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da00776 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +outlook_web_inbox_count_v*.zip diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..df5eaa9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,41 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a browser extension (v1.1) that enhances Outlook Web by displaying the total number of items next to the Inbox label. The extension uses a content script to monitor DOM changes and extract inbox count information from the page title. + +### Version History +- **v1.1**: Fixed browser hanging issues with throttling, improved MutationObserver, enhanced DOM selectors, reduced polling frequency +- **v1.0**: Initial release + +## Architecture + +- **manifest.json**: Chrome extension manifest (v3) defining permissions, content scripts, and metadata +- **content.js**: Main content script that runs on Outlook Web pages, implementing: + - DOM element detection for inbox labels + - Item count extraction from page titles using regex + - Dynamic count display with styling + - MutationObserver for real-time DOM change monitoring + - Periodic refresh mechanism (every 5 seconds after initial 10-second startup) + +## Development Workflow + +### Testing the Extension +1. Load as unpacked extension in Chrome/Edge developer mode +2. Navigate to https://outlook.office.com or https://outlook.office365.com +3. Check browser console for debug logs starting with "Outlook Inbox Count Extension" + +### Packaging +The .zip file contains the packaged extension ready for distribution. + +## Key Implementation Details + +- **Performance optimizations (v1.1)**: Throttling mechanism prevents excessive function calls, selective MutationObserver reduces CPU usage +- **Enhanced DOM detection**: Multiple fallback selectors and regex patterns for different Outlook Web versions +- **CSS selectors**: Primary `div[title^="Inbox -"][data-folder-name="inbox"]` with fallbacks for `button` elements and various title formats +- **Count extraction**: Multiple regex patterns including `/Inbox - (\d+) items/`, `/Inbox \((\d+)\)/`, `/(\d+) items/` +- **Duplicate prevention**: Uses `.added-count` class marking to avoid multiple count displays +- **Dynamic content handling**: Optimized interval polling (10s) and intelligent mutation observation +- **Graceful fallback**: Displays "(-)" when count unavailable \ No newline at end of file diff --git a/PUBLISHING.md b/PUBLISHING.md new file mode 100644 index 0000000..2b67c11 --- /dev/null +++ b/PUBLISHING.md @@ -0,0 +1,128 @@ +# Publishing to the Chrome Web Store + +This document describes how to release a new version of the Outlook Web Inbox +Count extension to the Chrome Web Store using the `publish.sh` script. + +## Quick release workflow + +For a routine release (once the one-time setup below is done): + +1. Edit `content.js` and bump the version in `manifest.json` (e.g. `1.4` -> `1.5`). +2. Add a changelog entry in the header of `content.js`. +3. Run: + ```bash + ./publish.sh + ``` + This builds `outlook_web_inbox_count_v.zip` from `manifest.json`, + uploads it, and publishes it. Chrome Web Store review typically takes a + few hours to a day before the new version goes live. + +Options: +- `./publish.sh --no-publish` - upload only, leave as Draft in the dashboard. +- `./publish.sh path/to/existing.zip` - upload a specific zip without rebuilding. + +## How it works + +`publish.sh` uses the [Chrome Web Store API](https://developer.chrome.com/docs/webstore/api): + +1. Exchanges the OAuth **refresh token** (from `.env`) for a short-lived + **access token** via `https://oauth2.googleapis.com/token`. +2. `PUT`s the zip to `upload/chromewebstore/v1.1/items/$EXTENSION_ID`. +3. `POST`s to `chromewebstore/v1.1/items/$EXTENSION_ID/publish` to submit the + uploaded draft for review. + +The script reads all credentials from a `.env` file in the project root +(gitignored). Required variables: + +``` +CLIENT_ID= +CLIENT_SECRET= +REFRESH_TOKEN= +EXTENSION_ID=<32-char chrome web store extension id> +``` + +The current extension ID is `mjdfjopdcoiojbjnfkpjhcnpefjknkdn`. + +## One-time setup (already done, for reference) + +### 1. Google Cloud OAuth project + +1. Create a project at https://console.cloud.google.com/. +2. Enable the **Chrome Web Store API** under "APIs & Services". +3. Configure the OAuth consent screen: + - User type: External + - Add yourself as a **test user** (required while the app is in Testing mode). +4. Create OAuth credentials: + - "Credentials" -> "Create Credentials" -> "OAuth client ID" + - Application type: **Desktop app** + - Save the `client_id` and `client_secret` to `.env`. + +### 2. Obtain a refresh token + +Open this URL in a browser (replace `$CLIENT_ID`): + +``` +https://accounts.google.com/o/oauth2/auth?response_type=code&scope=https://www.googleapis.com/auth/chromewebstore&client_id=$CLIENT_ID&redirect_uri=http://localhost&access_type=offline&prompt=consent +``` + +After approval, the browser will attempt to redirect to `http://localhost/?code=...` +and fail to connect - that is expected. **Copy the `code` value from the URL bar.** + +Exchange the code for a refresh token: + +```bash +curl "https://oauth2.googleapis.com/token" \ + -d "client_id=$CLIENT_ID" \ + -d "client_secret=$CLIENT_SECRET" \ + -d "code=$CODE" \ + -d "grant_type=authorization_code" \ + -d "redirect_uri=http://localhost" +``` + +Save the `refresh_token` value from the JSON response into `.env`. + +### 3. Find the extension ID + +Log into https://chrome.google.com/webstore/devconsole and open the extension's +edit page. The URL contains the 32-character extension ID: +`.../devconsole///edit`. + +## Refreshing credentials + +### If the refresh token has expired (7-day limit) + +While the OAuth consent screen is in **Testing** mode, refresh tokens expire +after **7 days**. If `publish.sh` fails with an access-token error, redo step +2 above (obtain a new code and exchange it) and update `REFRESH_TOKEN` in `.env`. + +**To avoid this**, publish the OAuth app (consent screen -> "Publish App"). +Since the only user is the developer and the scope +(`chromewebstore`) is self-owned, verification is not strictly required for +personal use - the app just needs to be in "In production" state. + +### Rotating the client secret + +If the `client_secret` is ever exposed, rotate it in the Google Cloud console +(Credentials page -> edit client -> Reset Secret) and update `.env`. You will +also need to obtain a new refresh token since the old one is tied to the old +secret. + +## Troubleshooting + +- **`Error 403: access_denied` during auth** - you are not added as a test + user on the OAuth consent screen. Add yourself in the console and retry. +- **`uploadState: FAILURE`** - usually means the zip is malformed, or the + version in `manifest.json` is not greater than the currently published + version. Bump the version and rebuild. +- **Publish returns `ITEM_PENDING_REVIEW`** - normal. The item has been + submitted and is in the Chrome review queue. +- **Refresh token suddenly invalid** - most likely the 7-day testing-mode + expiry. Obtain a new one (see above). + +## Files + +- `publish.sh` - build + upload + publish script. +- `.env` - credentials (gitignored; never commit). +- `.gitignore` - excludes `.env`. +- `manifest.json` - source of truth for the version number. +- `outlook_web_inbox_count_v.zip` - build artifact produced by `publish.sh`. diff --git a/content.js b/content.js new file mode 100644 index 0000000..6174460 --- /dev/null +++ b/content.js @@ -0,0 +1,323 @@ +// Outlook Web Inbox Count Extension v1.4 +// Changelog v1.4: +// - Added outlook.cloud.microsoft to manifest matches (Microsoft migrated Outlook Web to new domain) +// Changelog v1.3: +// - Fixed Firefox compatibility by adding browser_specific_settings to manifest +// - Fixed bug where findTotalItems() wasn't returning values due to forEach callbacks +// - Changed forEach loops to for loops to enable proper return statements +// - Extended retry mechanism with more attempts (1s, 2s, 3s, 5s, 8s, 10s, 15s, 20s) +// - Added re-logging of environment info on each retry to track page load progress +// Changelog v1.2: +// - Added comprehensive debugging information to help diagnose user issues +// - Added URL detection and page structure analysis +// - Enhanced logging for DOM element discovery +// - Added language/locale detection +// Changelog v1.1: +// - Fixed browser hanging issue by adding throttling mechanism +// - Improved MutationObserver to be more selective and prevent infinite loops +// - Enhanced DOM selectors with multiple fallbacks for different Outlook versions +// - Reduced polling frequency to improve performance +// - Added better pattern matching for inbox count extraction + +console.log("Outlook Inbox Count Extension v1.4: Script started"); + +// Enhanced debugging information +function logEnvironmentInfo() { + console.log("=== OUTLOOK INBOX EXTENSION DEBUG INFO ==="); + console.log("URL:", window.location.href); + console.log("Domain:", window.location.hostname); + console.log("User Agent:", navigator.userAgent); + console.log("Language:", navigator.language); + console.log("Document ready state:", document.readyState); + console.log("Page title:", document.title); + + // Check for New Outlook indicators + const isNewOutlook = document.querySelector('[data-app-section="NewMailModule"]') || + document.querySelector('div[role="main"][data-app-section]') || + document.body.classList.contains('new-outlook') || + window.location.href.includes('mail.office365.com'); + console.log("Detected New Outlook interface:", isNewOutlook); + + // Log all elements that might be inbox-related + console.log("=== DOM ANALYSIS ==="); + const potentialInboxElements = document.querySelectorAll('[data-folder-name], [title*="Inbox"], [aria-label*="Inbox"], button[title*="nbox"], div[title*="nbox"]'); + console.log("Found", potentialInboxElements.length, "potential inbox elements:"); + potentialInboxElements.forEach((el, i) => { + console.log(`Element ${i}:`, { + tagName: el.tagName, + className: el.className, + title: el.getAttribute('title'), + dataFolderName: el.getAttribute('data-folder-name'), + ariaLabel: el.getAttribute('aria-label'), + textContent: el.textContent?.substring(0, 100) + }); + }); + console.log("=== END DOM ANALYSIS ==="); +} + +let isProcessing = false; +let lastProcessTime = 0; +const THROTTLE_DELAY = 1000; // 1 second throttle + +function findInboxElement() { + console.log("=== SEARCHING FOR INBOX ELEMENT ==="); + + // Try multiple selectors to find inbox element + const selectors = [ + 'div[title^="Inbox -"][data-folder-name="inbox"]', + 'div[data-folder-name="inbox"]', + 'div[title*="Inbox"]', + 'button[title^="Inbox -"]', + 'button[data-folder-name="inbox"]', + // Additional selectors for different Outlook versions + 'button[aria-label*="Inbox"]', + 'div[aria-label*="Inbox"]', + '[data-folder-id="inbox"]', + '[data-automation-id*="inbox"]', + 'span[title*="Inbox"]' + ]; + + console.log("Trying", selectors.length, "different selectors..."); + + for (let i = 0; i < selectors.length; i++) { + const selector = selectors[i]; + console.log(`Trying selector ${i + 1}/${selectors.length}:`, selector); + + const elements = document.querySelectorAll(selector); + console.log(`Found ${elements.length} elements with this selector`); + + if (elements.length > 0) { + elements.forEach((el, idx) => { + console.log(` Element ${idx}:`, { + tagName: el.tagName, + className: el.className, + title: el.getAttribute('title'), + ariaLabel: el.getAttribute('aria-label'), + dataFolderName: el.getAttribute('data-folder-name'), + textContent: el.textContent?.substring(0, 50) + '...' + }); + }); + + const inboxDiv = elements[0]; // Use the first match + console.log("✓ Using first element as inbox:", inboxDiv); + + const span = inboxDiv.querySelector('span:not(.added-count)'); + if (span) { + console.log("✓ Found inner span element:", span); + return span; + } + // If no span found, try to use the element itself + console.log("ℹ No inner span found, using element itself"); + return inboxDiv; + } + } + + console.log("❌ Inbox element not found with any selector"); + console.log("=== END INBOX ELEMENT SEARCH ==="); + return null; +} + +function findTotalItems() { + console.log("=== SEARCHING FOR TOTAL ITEMS COUNT ==="); + + // Try multiple selectors and patterns + const selectors = [ + 'div[title^="Inbox -"][data-folder-name="inbox"]', + 'div[data-folder-name="inbox"]', + 'div[title*="Inbox"]', + 'button[title^="Inbox -"]', + 'button[data-folder-name="inbox"]', + 'button[aria-label*="Inbox"]', + 'div[aria-label*="Inbox"]', + '[data-folder-id="inbox"]' + ]; + + const patterns = [ + /Inbox - (\d+) items/i, + /Inbox \((\d+)\)/i, + /(\d+) items/i, + /\((\d+)\)/i, + // Additional patterns for different languages/formats + /Inbox.*?(\d+)/i, + /(\d+).*?unread/i, + /(\d+).*?message/i, + /(\d+).*?email/i + ]; + + // Also check document title which often contains count + console.log("Checking document title:", document.title); + for (const pattern of patterns) { + const match = document.title.match(pattern); + if (match) { + const totalItems = match[1]; + console.log(`✓ Total items found in document title: ${totalItems}`); + return totalItems; + } + } + + console.log("Checking DOM elements..."); + for (const selector of selectors) { + const elements = document.querySelectorAll(selector); + console.log(`Checking selector: ${selector} (${elements.length} elements)`); + + for (let idx = 0; idx < elements.length; idx++) { + const inboxDiv = elements[idx]; + const sources = [ + { name: 'title', value: inboxDiv.getAttribute('title') }, + { name: 'aria-label', value: inboxDiv.getAttribute('aria-label') }, + { name: 'textContent', value: inboxDiv.textContent } + ]; + + for (const source of sources) { + if (!source.value) continue; + + console.log(` Element ${idx} ${source.name}:`, source.value.substring(0, 100)); + + for (const pattern of patterns) { + const match = source.value.match(pattern); + if (match) { + const totalItems = match[1]; + console.log(`✓ Total items found in ${source.name}: ${totalItems}`); + return totalItems; + } + } + } + } + } + + console.log("❌ Total items count not found in any source"); + console.log("=== END TOTAL ITEMS SEARCH ==="); + return null; +} + +function addInboxCount() { + const now = Date.now(); + + // Throttle execution to prevent excessive calls + if (isProcessing || (now - lastProcessTime) < THROTTLE_DELAY) { + return; + } + + isProcessing = true; + lastProcessTime = now; + console.log("addInboxCount function called"); + + const inboxElement = findInboxElement(); + if (!inboxElement) { + console.log("Inbox element not found, will try again later"); + isProcessing = false; + return; + } + + const totalItems = findTotalItems(); + console.log("Total items:", totalItems); + + // Check if we've already added the count + let countSpan = inboxElement.querySelector('.added-count'); + if (!countSpan) { + // Create a new span element for the count + countSpan = document.createElement('span'); + countSpan.style.marginLeft = '5px'; + countSpan.style.fontWeight = 'bold'; + countSpan.className = 'added-count'; + + // Add the count next to the inbox label + inboxElement.appendChild(countSpan); + console.log("Count span added to inbox label"); + } + + // Update the count + if (totalItems) { + countSpan.textContent = ` (${totalItems})`; + countSpan.style.color = ''; // Reset to default color + } else { + countSpan.textContent = ' (-)'; + countSpan.style.color = '#888'; // Grey color + } + console.log("Count span updated"); + isProcessing = false; +} + +function init() { + console.log("=== INITIALIZING OUTLOOK INBOX COUNT EXTENSION ==="); + + // Log environment information first + logEnvironmentInfo(); + + console.log("Starting initial inbox count attempt..."); + addInboxCount(); + + // Try to add the inbox count multiple times during startup with extended delays + // Firefox may need more time for Outlook's dynamic content to load + console.log("Scheduling additional attempts at 1s, 2s, 3s, 5s, 8s, 10s, 15s, and 20s..."); + const retryTimes = [1000, 2000, 3000, 5000, 8000, 10000, 15000, 20000]; + + retryTimes.forEach(delay => { + setTimeout(() => { + console.log(`=== ${delay/1000}-SECOND RETRY ===`); + logEnvironmentInfo(); // Re-log to see if page has loaded + addInboxCount(); + }, delay); + }); + + // Then, check every 15 seconds (slightly increased for debugging) + setInterval(() => { + console.log("=== PERIODIC CHECK ==="); + addInboxCount(); + }, 15000); + + console.log("=== INITIALIZATION COMPLETE ==="); +} + +// Use a MutationObserver to watch for changes in the DOM +const observer = new MutationObserver((mutations) => { + let shouldUpdate = false; + + for (let mutation of mutations) { + // Only update if attributes changed on elements that might be inbox-related + if (mutation.type === 'attributes' && mutation.attributeName === 'title') { + const target = mutation.target; + if (target.hasAttribute && target.hasAttribute('data-folder-name')) { + shouldUpdate = true; + break; + } + } + // Or if new inbox elements were added + else if (mutation.type === 'childList') { + const nodes = [...(mutation.addedNodes || [])]; + if (nodes.some(node => node.nodeType === 1 && + (node.hasAttribute?.('data-folder-name') || + node.querySelector?.('[data-folder-name="inbox"]')))) { + shouldUpdate = true; + break; + } + } + } + + if (shouldUpdate) { + addInboxCount(); + } +}); + +// Start observing with more targeted configuration +observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['title', 'data-folder-name'] +}); + +// Run the init function when the DOM is fully loaded +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); +} else { + init(); +} + +// Listen for messages from the background script +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.message === "URL_CHANGED") { + console.log("URL changed, running inbox count function"); + addInboxCount(); + } +}); diff --git a/icon128.png b/icon128.png new file mode 100644 index 0000000..487087d Binary files /dev/null and b/icon128.png differ diff --git a/icon16.png b/icon16.png new file mode 100644 index 0000000..91d1383 Binary files /dev/null and b/icon16.png differ diff --git a/icon64.png b/icon64.png new file mode 100644 index 0000000..58b2a91 Binary files /dev/null and b/icon64.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..146a4a5 --- /dev/null +++ b/manifest.json @@ -0,0 +1,34 @@ +{ + "manifest_version": 3, + "name": "Outlook Web Inbox Count", + "version": "1.4", + "description": "Adds the total number of items next to the Inbox label in Outlook Web", + "author": "Giorgio Gilestro", + "homepage_url": "https://lab.gilest.ro", + "browser_specific_settings": { + "gecko": { + "id": "outlook-inbox-count@gilest.ro" + } + }, + "permissions": [], + "host_permissions": [ + "https://outlook.office.com/*", + "https://outlook.office365.com/*", + "https://outlook.cloud.microsoft/*" + ], + "content_scripts": [ + { + "matches": [ + "https://outlook.office.com/*", + "https://outlook.office365.com/*", + "https://outlook.cloud.microsoft/*" + ], + "js": ["content.js"] + } + ], + "icons": { + "16": "icon16.png", + "64": "icon64.png", + "128": "icon128.png" + } +} diff --git a/publish.sh b/publish.sh new file mode 100755 index 0000000..1929c00 --- /dev/null +++ b/publish.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Build and publish the extension to the Chrome Web Store. +# +# Reads the version from manifest.json, builds outlook_web_inbox_count_v.zip +# (overwriting if present), then uploads and publishes it via the Chrome Web Store API. +# +# Usage: +# ./publish.sh # build from manifest.json, upload, and publish +# ./publish.sh --no-publish # upload only (leave in Draft state in the dashboard) +# ./publish.sh path/to/extension.zip # skip build, upload a specific zip +# +# Requires a .env file in the project root with: +# CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN, EXTENSION_ID +# See PUBLISHING.md for how to obtain these. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# --- Parse arguments --- +PUBLISH=true +ZIP_ARG="" +for arg in "$@"; do + case "$arg" in + --no-publish) PUBLISH=false ;; + -h|--help) + sed -n '2,15p' "$0" + exit 0 + ;; + *) ZIP_ARG="$arg" ;; + esac +done + +# --- Load credentials --- +if [[ ! -f .env ]]; then + echo "Error: .env file not found (see PUBLISHING.md)" >&2 + exit 1 +fi +set -a +# shellcheck disable=SC1091 +source .env +set +a + +for var in CLIENT_ID CLIENT_SECRET REFRESH_TOKEN EXTENSION_ID; do + if [[ -z "${!var:-}" ]]; then + echo "Error: $var is not set in .env" >&2 + exit 1 + fi +done + +# --- Determine / build zip --- +if [[ -n "$ZIP_ARG" ]]; then + ZIP="$ZIP_ARG" + if [[ ! -f "$ZIP" ]]; then + echo "Error: $ZIP not found" >&2 + exit 1 + fi +else + VERSION=$(python3 -c "import json; print(json.load(open('manifest.json'))['version'])") + ZIP="outlook_web_inbox_count_v${VERSION}.zip" + echo "Building $ZIP from manifest version $VERSION..." + rm -f "$ZIP" + zip -q "$ZIP" manifest.json content.js icon16.png icon64.png icon128.png +fi +echo "Package: $ZIP" + +# --- Get a fresh access token --- +echo "Fetching access token..." +ACCESS_TOKEN=$(curl -s "https://oauth2.googleapis.com/token" \ + -d "client_id=${CLIENT_ID}" \ + -d "client_secret=${CLIENT_SECRET}" \ + -d "refresh_token=${REFRESH_TOKEN}" \ + -d "grant_type=refresh_token" \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('access_token',''))") + +if [[ -z "$ACCESS_TOKEN" ]]; then + echo "Error: failed to obtain access token. Refresh token may have expired." >&2 + echo "See PUBLISHING.md 'Refreshing credentials' section." >&2 + exit 1 +fi + +# --- Upload --- +echo "Uploading to extension $EXTENSION_ID..." +UPLOAD_RESPONSE=$(curl -s \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "x-goog-api-version: 2" \ + -X PUT \ + -T "$ZIP" \ + "https://www.googleapis.com/upload/chromewebstore/v1.1/items/$EXTENSION_ID") +echo "Upload response: $UPLOAD_RESPONSE" + +UPLOAD_STATE=$(echo "$UPLOAD_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('uploadState',''))" 2>/dev/null || echo "") +if [[ "$UPLOAD_STATE" != "SUCCESS" ]]; then + echo "Error: upload did not succeed (state: $UPLOAD_STATE)" >&2 + exit 1 +fi + +# --- Publish --- +if [[ "$PUBLISH" == "true" ]]; then + echo "Publishing..." + PUBLISH_RESPONSE=$(curl -s \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "x-goog-api-version: 2" \ + -H "Content-Length: 0" \ + -X POST \ + "https://www.googleapis.com/chromewebstore/v1.1/items/$EXTENSION_ID/publish") + echo "Publish response: $PUBLISH_RESPONSE" +else + echo "Skipping publish step (--no-publish). The upload is in Draft state in the dashboard." +fi + +echo "Done."