Add a reliable "Copy deep link" control in Gmail that produces RFC822 Message-ID-based URLs, which are stable across recipients and accounts.
Role: Observes Gmail DOM and injects UI elements
Key Functions:
startObserver()- Sets up MutationObserver for Gmail's SPAscanConversationView()- Finds message bubbles with[data-legacy-message-id]scanThreadListView()- Finds thread rows with[data-legacy-thread-id]ensureButton()- Injects "⧉ Copy link" button if not already presentshowToast()- Displays temporary notification
DOM Selectors:
[data-legacy-message-id]- Gmail's internal message ID on conversation bubbles[data-legacy-thread-id]- Gmail's internal thread ID on list view rows.gH- Message header bar in conversation viewspan.bog- Subject line in thread list view
Selector Strategy:
Gmail's DOM is built as a Single Page Application (SPA) that updates dynamically. The selectors used (data-legacy-*) have been stable for years and are less likely to change than class names or structure. If selectors fail, buttons simply won't render - the extension fails gracefully without breaking Gmail.
Role: OAuth token management and Gmail API integration
Key Functions:
getToken()- Obtains OAuth token via Chrome Identity APIauthedGetJson()- Makes authenticated Gmail API calls with 401 retrygetMessageIdHeaderByMessage()- Fetches Message-ID for a specific messagegetMessageIdHeaderForLastInThread()- Fetches Message-ID for last message in threadnormalizeMessageId()- Strips angle brackets from Message-IDbuildDeepLink()- Constructs URL-escaped deep link
Caching:
- In-memory Map with 2-minute TTL
- Cache keys:
msg:{gmailMessageId}andthread-last:{threadId} - Reduces API calls for repeated requests
Error Handling:
- Non-interactive token request first, falls back to interactive
- 401 errors trigger token invalidation and retry
- All errors propagated to content script as
{ ok: false, error: string }
Role: User preferences
Settings:
- Enable/disable buttons in conversation view
- Enable/disable buttons in thread list view
- Stored in
chrome.storage.sync
(Note: Current implementation doesn't enforce these settings in content.js - future enhancement)
User clicks "⧉ Copy link"
↓
content.js: ensureButton() callback triggered
↓
chrome.runtime.sendMessage({ type: "getDeepLinkForMessage", gmailMessageId })
↓
service-worker.js: onMessage listener receives request
↓
Check cache for Message-ID
↓
If not cached: Gmail API call
GET https://gmail.googleapis.com/gmail/v1/users/me/messages/{id}?format=metadata&metadataHeaders=Message-ID
↓
Extract "Message-ID" header from response
↓
Strip angle brackets: "<abc@x.com>" → "abc@x.com"
↓
Build deep link: https://mail.google.com/mail/#search/rfc822msgid%3A{encoded}
↓
Cache result (2 min TTL)
↓
sendResponse({ ok: true, url })
↓
content.js: receives response
↓
navigator.clipboard.writeText(url)
↓
showToast("Deep link copied")
https://mail.google.com/mail/#search/rfc822msgid%3A<URL-encoded-Message-ID>
- Universal: Message-ID is standard across email systems (RFC 822/5322)
- Stable: Doesn't change when forwarding, archiving, or across accounts
- Unique: Guaranteed unique identifier for each message
- Portable: Works for any user with access to the message
Gmail uses /u/N for account index in multi-account setups. Omitting it makes links work regardless of:
- Which account index the user has
- Whether they've switched accounts
- Whether they're using single or multi-account mode
| Permission | Purpose |
|---|---|
identity |
OAuth 2.0 authentication with Google |
storage |
Save user preferences (options page) |
scripting |
Inject content script into Gmail |
activeTab |
Access active tab for script injection |
https://mail.google.com/* |
Host permission for Gmail DOM access |
https://gmail.googleapis.com/* |
Host permission for Gmail API calls |
https://www.googleapis.com/auth/gmail.readonly- Read-only access to Gmail
-
Content Security Policy: Strict CSP prevents code injection
"script-src 'self'; object-src 'none'; base-uri 'none';" -
No external scripts: All code bundled with extension
-
Minimal scope: Only
gmail.readonly- cannot send, delete, or modify emails -
Token handling: Chrome Identity API manages tokens securely
- No data collection: Extension doesn't send data anywhere except Gmail API
- No persistent storage: Headers cached in memory only (2 min TTL)
- Local processing: All URL building happens in service worker
- No tracking: No analytics, telemetry, or user behavior monitoring
What this protects against:
- Malicious external scripts
- Data exfiltration
- Unauthorized email access beyond reading
- XSS attacks via CSP
What this doesn't protect against:
- User granting extension to malicious actor
- Compromised Google account
- Gmail API outages
- Browser/extension platform vulnerabilities
- Create project
- Enable Gmail API
- Create OAuth 2.0 Client ID
- Set application type: Chrome Extension
- Configure authorized redirect URI:
https://<EXTENSION_ID>.chromiumapp.org/ - Copy Client ID to
manifest.json
- User clicks button
- Service worker requests token non-interactively → fails (no token yet)
- Requests interactively → Chrome shows OAuth consent screen
- User approves → token cached by Chrome Identity API
- Future requests use cached token (non-interactive)
- Single MutationObserver: One observer for entire Gmail DOM
- Cheap scans: Query only for specific data attributes
- Short cache TTL: Balance API calls vs. stale data
- No polling: Event-driven architecture
- Lazy injection: Buttons created only when needed
- Initial page load: ~10-50ms for first scan
- SPA navigation: ~5-20ms for incremental scans
- Button click: ~100-500ms (OAuth + API + clipboard)
- Memory: ~1-2MB for extension + cache
| Failure | Behavior |
|---|---|
| OAuth fails | Toast shows error, user can retry |
| API call fails | Error propagated to UI, shows toast |
| Clipboard access denied | Toast shows error |
| DOM selector not found | Button not injected, no visible error |
| Message-ID missing | Error toast shown |
| Network offline | API call fails, error shown |
Service Worker Console:
chrome://extensions → "service worker" linkContent Script Console:
Open Gmail → DevTools (F12) → Console tabCommon Issues:
- 401 Unauthorized: Client ID mismatch or API not enabled
- Buttons not appearing: DOM selectors changed or page not fully loaded
- "Unknown error": Check service worker console for details
- Gmail web only: Doesn't work in mobile apps or other email clients
- Requires Message-ID header: Rare messages without it will fail
- Requires read access: Respects Gmail's permission model
- DOM dependency: Subject to Gmail UI changes
- Chrome/Chromium only: Manifest V3, Chrome Identity API
- Enforce options settings: Check
chrome.storage.syncbefore injecting buttons - Keyboard shortcuts: Add hotkey for current message
- Omnibox command: Type command to copy link for open message
- Better error messages: Specific guidance for each error type
- Batch operations: Copy links for multiple selected messages
- Alternative selectors: Fallback strategies if primary selectors fail
- Load extension in Chrome
- Authorize OAuth (first time)
- Test conversation view buttons
- Test thread list view buttons
- Verify clipboard has correct URL
- Paste URL in new tab, verify it opens correct message
- Test with multiple accounts
- Test in different Gmail views (inbox, sent, search results)
- Test error cases (revoke OAuth, disable API)
Potential Playwright tests:
- Button injection in mock Gmail DOM
- Clipboard copy with mock API responses
- OAuth flow simulation
- Error handling scenarios
All functions documented with:
- Description
@paramfor each parameter with type@returnswith return type- Error conditions noted
- ES6+ syntax
- Async/await for promises
- Arrow functions for callbacks
- Single responsibility functions
- Descriptive variable names
- Service worker instead of background page
chrome.identityfor OAuth- No remote code execution
- Strict CSP
- Host permissions instead of broad permissions