Skip to content

Thunderbird Support for Browserpass#395

Draft
thardeck wants to merge 1 commit intobrowserpass:masterfrom
thardeck:add_support_for_thunderbird
Draft

Thunderbird Support for Browserpass#395
thardeck wants to merge 1 commit intobrowserpass:masterfrom
thardeck:add_support_for_thunderbird

Conversation

@thardeck
Copy link
Copy Markdown

@thardeck thardeck commented Dec 5, 2025

Summary (Edited)

This PR adds Thunderbird email client support to the Browserpass browser extension. It allows users to store and retrieve email credentials (IMAP, SMTP, POP3, NNTP) and OAuth tokens (Gmail, Microsoft, Fastmail) from their pass password store instead of Thunderbird's built-in password manager.

I am already running the implementation locally since a few days. I have tried different setups, fixed several issues on the way and also ran into Thunderbird issues. For debugging purposes I have added quite some logging, which should not hurt and probably at least be kept until more users tried the extension.

I am interested in this feature myself and got some time to work on this. It turned out to be much more code then originally anticipated but there were a lot of edge cases to cover and will probably be some more. Any feedback is appreciated.

Features

  • Mail Protocol Support: Automatic password retrieval for IMAP, SMTP, POP3, and NNTP accounts
  • OAuth2 Token Storage: Single refresh token per provider hostname for Gmail, Microsoft, and Fastmail
  • CalDAV/CardDAV: OAuth authentication for calendar and contacts via OAuth2Module and OAuth2.prototype.connect hooks
  • Explicit Migration: User-triggered migration of credentials from Thunderbird's password manager to pass via the preferences page
  • Credential Saving: New credentials entered in Thunderbird are automatically saved to pass
  • OAuth Window Autofill: Clipboard-based autofill for OAuth browser windows with keyboard shortcuts and a hint banner
  • Concurrent Lookup Serialization: Parallel CalDAV/CardDAV syncs are deduplicated so only one GPG decrypt runs per token, reducing YubiKey touches
  • MV3 Background Resilience: Handles background script death during long-running GPG decrypts with retry logic and active wake-up

Technical Implementation

Why Separate Builds?

Thunderbird requires WebExtension Experiments to hook into its authentication system, which Firefox doesn't allow for non-privileged add-ons. The experiment API provides access to:

  • MsgAuthPrompt - Intercepts IMAP/SMTP/POP3/NNTP password prompts
  • OAuth2Module - Intercepts OAuth token requests (getRefreshToken/setRefreshToken) for CalDAV/CardDAV
  • OAuth2.prototype.connect - Populates refresh tokens before Thunderbird's own check, addressing CalDAV startup issues
  • browserRequest - Monitors OAuth browser windows for autofill

Therefore, the extension is built separately:

  • Firefox: make firefox - Standard browser extension
  • Thunderbird: make thunderbird - Includes experimental credentials API

The core extension code remains shared; only the manifest and experiment files differ.

Architecture

Thunderbird Auth Request
        ↓
implementation.js (WebExtension Experiment, privileged code)
  - Hooks MsgAuthPrompt (IMAP/SMTP/POP3/NNTP)
  - Hooks OAuth2Module (CalDAV/CardDAV token retrieval)
  - Hooks OAuth2.prototype.connect (pre-populate tokens)
  - Hooks browserRequest (OAuth browser windows)
        ↓
Event Emitters → background.js
        ↓
thunderbird.js (credential matching + storage)
        ↓
browserpass-native (GPG decryption via native messaging)

Key Design: Pass as Sole Credential Store

Tokens are never written to Thunderbird's built-in loginManager. Pass is the sole persistent store. Only null tokens (auth failure / revocation) are propagated to clear stale loginManager entries. This means on first use (or after any loginManager clear), CalDAV may trigger a startup OAuth window due to Thunderbird bug 2008995. Enabling offline caching for CalDAV calendars is the recommended workaround.

Synchronous Bridge

Thunderbird's auth callbacks (MsgAuthPrompt.promptPassword, OAuth2Module.getRefreshToken) are synchronous, but native messaging and the WebExtension background are async. The awaitSync() helper spins Mozilla's event loop (processNextEvent()) to bridge this gap, with abort detection for background script death.

Requirements

  • Thunderbird 128.0+: Uses WebExtension Experiments API
  • browserpass-native: Required for GPG decryption - run make hosts-thunderbird-user to register
  • pass: Standard Unix password manager with GPG

Testing

  1. Build extension: make thunderbird
  2. Package as XPI: cd thunderbird && zip -r browserpass-thunderbird.xpi *
  3. Install in Thunderbird via Add-ons → Install from File
  4. Open Thunderbird Error Console with Ctrl+Shift+J
  5. Test credential retrieval for existing email accounts
  6. Test OAuth authentication for Google/Microsoft accounts
  7. Test migration via Browserpass preferences → "Migrate credentials from Thunderbird"

Password Store Organization

~/.password-store/
└── thunderbird/
    ├── imap-mail.example.com.gpg        # IMAP server credentials
    ├── smtp-mail.example.com.gpg        # SMTP server credentials
    ├── oauth-accounts.google.com.gpg    # OAuth token (single per provider)
    └── https-sso.example.com.gpg        # OAuth identity provider credentials

All Thunderbird credentials are stored under thunderbird/ with {protocol}-{hostname} naming. OAuth uses a single token per provider hostname, matching Thunderbird's internal approach. The https entries are for OAuth identity provider login pages that appear in the OAuth browser window.

Design Decisions

1. Offline Startup Control

Thunderbird is forced to start offline to prevent auth requests before the extension is ready. Once hooks are registered, Thunderbird goes online automatically. User's "Always offline" preference is respected.

2. OAuth Token Caching

Tokens retrieved from pass are cached in memory for 8 hours to reduce GPG decryption overhead. Cache is cleared on token update or after the timeout expires.

3. Credential Migration

Migration is explicit and user-triggered via a button in the Browserpass preferences page (only visible in Thunderbird). Credentials are migrated from Thunderbird's password manager to pass. Existing pass entries are not overwritten.

4. Single OAuth Token Per Provider

OAuth tokens are stored as one file per provider hostname (thunderbird/oauth-{provider}.gpg), matching Thunderbird's internal approach of one token per server. The scope is stored inside the file for reference.

5. Concurrent Lookup Serialization

A pendingLookups set ensures only one GPG decrypt runs at a time per token key. Parallel CalDAV/CardDAV syncs spin-wait on the pending lookup rather than starting duplicate decrypts. This is critical for YubiKey users who must physically touch their key for each decrypt.

6. Failed Auth Tracking

The extension detects repeated auth prompts for the same account (indicating wrong credentials) and falls back to allowing manual password entry rather than entering an infinite retry loop.

Known Limitations

  • CalDAV Startup OAuth Windows: Thunderbird bug 2008995 causes CalDAV to trigger an OAuth browser window at startup before the extension hook is installed. Because pass is the sole persistent store and nothing is written to loginManager, this window will appear on first use and after any loginManager clear. Dismiss or complete the window to proceed; CalDAV syncs normally afterwards. The recommended workaround is to enable offline caching for all CalDAV calendars (Calendar → Properties → Offline Support → Keep a local copy), which prevents any network authentication request at startup.
  • OAuth Autofill Method: OAuth browser window autofill uses clipboard-based injection with keyboard shortcuts rather than direct DOM manipulation, due to security restrictions in browser windows.
  • MV3 Background Lifecycle: The background script unloads after ~30s of inactivity, which can interrupt long-running GPG decrypts. The extension handles this with retry logic and active background wake-up calls.

Breaking Changes

None - this is additive functionality that is mainly added to the Thunderbird extension.

Related PRs

A companion PR to browserpass-native is required to register the native host for Thunderbird:

  • Adds hosts-thunderbird-user and hosts-thunderbird Makefile targets
  • Registers firefox-host.json native messaging manifest

References

@max-baz
Copy link
Copy Markdown
Member

max-baz commented Dec 6, 2025

This is an impressive amount of work, thanks for sharing! It will take some time to go through, as it's quite a lot of code, but I'll try to not delay it much.

@erayd do you have time to review this as well?

A few initial questions & remarks, mostly based on PR description:

  1. It looks like OAuth tokens are cached in memory, but regular credentials are not? How come, is the frequency really that different? I'd imagine that synchronizing email over regular IMAP will happen just as frequently as using OAuth tokens?
  2. I'm wondering whether credentials migration is not a bit too invasive? I understand the convenience, but an unprompted dump of credentials feels a bit off. Have you considered alternatives, e.g. a shell script to explicitly call, to perform the migration? Not saying that this is a way to go, just asking.
  3. Have you considered some alternatives to the password store organization? Again, not saying that your choice is wrong, but curios to explore ideas. For example, instead of multiple files with the same name under different folders, why not have all that info in a single file, that needs to be decrypted once? Instead of enforcing a specific path, why not make use of the popup to find the credentials anywhere in the password store, and perhaps cache the last choice with the ability to override it?
  4. I didn't really understand the "Known limitations" section, maybe it will get clear once I test it... But is it something you would like to address before merging the PR, or this is just explaining reasoning behind the implementation details?
  5. GPG decryption overheard is a good point you mention, I would say that even more important point is that quite a lot of users of this extensions have Yubikeys, which requires a physical touch for every single decryption operation (some have a few seconds cache, but not all).
  6. In order to avoid merging the PR while you are still working on it, could you drop a note here whenever you feel like you have completed all the work you had in mind for the PR and are happy to see it merged? The same would apply once we go through code review and potentially think of some improvement ideas.

@thardeck
Copy link
Copy Markdown
Author

thardeck commented Dec 7, 2025

Many thanks for your quick response.

  1. Good question. It seems that the IMAP password gets cached by Thunderbird already - that's why I never looked into it. I will test this over a longer time now though to be sure.
  2. Yes, I thought about this too. I does feels a bit invasive, at the same time the credentials are only copied from a local encrypted storage to another local encrypted storage, so it is not like we are transferring something to the cloud.
    Initially I have tried to read the credentials from pass, when they are not there fall back to the Thunderbird password storage, and then migrate that credential. I ran into quite some issues with this though and it seemed overly complex, so I went for the one-time migration instead. Possible solutions:
    1. Show a notification asking the user for migration. This would also require to store if the migration was tried and probably have a button to restart it in the Browserpass preferences. Feels a bit complex in terms of UI interaction and shared extension code.
    2. Same as above, but only explaining how to migrate and having a button the in the Browserpass preferences.
    3. Shell script might be an option but not a very convenient one. I would also somehow need to be able to access the Thunderbird password store, which will most likely need quite some additional code (did not verify if there is a command line tool for it).
    4. On-demand migration, like I have mentioned, if pass does not provide the credentials look them up in the password storage and migrate them. This variant does not provide a clear separation between those two password storages though (you do not know where the credentials come from).
  3. In regards to directory structure I am flexible, but I did not think about and would not like the idea to deviate from the pass concept and store all Thunderbird credentials in one file.
    Having one file means I need a file structure concept and parsing which should be pass compatible but at the same time stores multiple passwords and tokens in one file.
    With the current file structure, you can easily link the SMTP password to IMAP withouth any additional implementation required. The same you could do for OAuth tokens.
    You can link your HTTPS OAuth Login password to your regular Firefox pass directory, so have every credential only in one place while everything else links to it.

    In regards to a popup selecting the relevant credential, to me Thunderbird authentication should not be interactive. I have my mail and calendar and so on in it and would not like to be asked on every restart which credential I would like to enter or go through a dir structure. It should just work without the possibility of selecting a wrong domain, similar to a passkey.

    Since Thunderbird does usually not have that many credentials, what about a single directory with protocols in front:
~/.password-store/
└── thunderbird/
    └── imap-mail.example.com.gpg            # IMAP server credentials
    └── smtp-mail.example.com.gpg            # SMTP server credentials
    └── oauth-mail-sso.example.com.gpg       # OAuth mail tokens
    └── https-sso.example.com.gpg            # OAuth identity provider credentials
  1. Fair point, maybe I should have phrased it differently.
    I am not directly planning to work on those although I would like to report the underlying issue of the first mentioned "limitation" against Thunderbird.
    Since the workaround for the Thunderbird issue is that my implementation closes a window at the start, I thought it might be good to mention this in the pull request.

    When trying to implement the OAuth Autofill I ran into quite some issues with the internal Thunderbird browser's security restrictions, and came up with the clipboard solution which I started to like. I am open to look at this again though if you prefer it - but OAuth re-authentication should not happen that often anyway.
  2. Yes, I also have a Yubikey. That's why I have implemented the eight hour caching which I thought might be not liked by some.
    I have also fixed a matching issue in this regards when searching for the OAuth token.

    The OAuth implementation might be overengineered and could potentially be improved in regards to decryption overhead, because Thunderbird at the moment only stores one OAuth token in its password storage per domain.
    When I tested this though I realized - at least in combination with Google - that if you remove your token and the calendar is checked first - then Google provides you with a token which only has permissions for the calendar, so this would break your carddav and caldav authentication. This would lead in the best case (Thunderbird carddav at the moment just fails silently in case of authentication issues) to reauthentication windows and so on.
    In general the OAuth token implementation in Thunderbird feels still a bit experimental but is also quite complex anyway.

    Maybe the best and least complex solution would be to just go for a single token and when there is an issue the user needs to manually make sure to use the correct OAuth authentication path.
  3. I think my pull request is ready for review, but if you know already that certain behavior should be changed - the migration for example or OAuth simplification - then I would implement that before your initial review - so let me know beforehand.
    There is always room for improvement but at a certain point it is good to get feedback and then iterate on that.

@thardeck
Copy link
Copy Markdown
Author

thardeck commented Dec 16, 2025

I am using the extension now for over two weeks and did not run into any issues so far.
Nevertheless I am planning to refactor at least the following things over Christmas.

  • Most likely reduce the logic to use one OAuth token per domain/username to simplify the whole logic - I will test around a bit if the permission issue can be worked around
  • Look into the internal caching, because it seems Thunderbird also caches Oauth tokens too - during testing I played too much around with removing/re-adding accounts, restarting Thunderbird and changing things to realize this and it seems Thunderbird caches the tokens per task, so different calendars still ask each time for the same token
  • Try to implement the on-demand migration, so requested credentials, which are not in pass but in Thunderbird password storage - should be migrated

Let me know if anything else should be changed.

@thardeck
Copy link
Copy Markdown
Author

thardeck commented Jan 7, 2026

I looked into this over the holidays and it is possible to consolidate the OAuth token to one entry.
The issue is that Thunderbird does ignore the offline mode for the calendar and when there is no OAuth entry in the password storage, then it requests in case of GMail a new token which seems to recall the previous one forcing for a re-authentication in case of the calendar.
The new token though has only permissions for the Google Calendar so the carddav and mail would fail if we only have one token.

While my initial implementation takes care of the issue, it is still not as nice as having a consolidated token, especially when a Yubikey with manual interaction for each encryption is used.
A work around is to have the token also in the Thunderbird password storage - so it is there during start - but this kind of defeats the purpose of this addon.

So I think the issue needs to be fixed in Thunderbird first. I will create an issue and maybe try to fix the issue myself when I have some spare time.

I will keep this thread updated.

@thardeck thardeck marked this pull request as draft April 26, 2026 14:32
Intercept Thunderbird's authentication prompts (IMAP, SMTP, POP3,
NNTP) and OAuth2 token requests (CalDAV, CardDAV, mail) to retrieve
credentials from the pass password store via GPG decryption.

Credentials are stored under thunderbird/{protocol}-{hostname} in
pass. A single OAuth refresh token is kept per provider hostname.
Pass is the sole credential store — tokens are never written to
Thunderbird's built-in password manager.

A WebExtension Experiment (implementation.js) hooks MsgAuthPrompt,
OAuth2Module, and OAuth2.prototype.connect to bridge Thunderbird's
synchronous auth callbacks with the async native messaging host.
Concurrent CalDAV calendar syncs are serialised through a pending-
lookups set to avoid multiple YubiKey touches for the same token.

The OAuth2.prototype.connect hook addresses Thunderbird bug 2008995
where CalDAV creates the shared OAuth2 instance before the extension
hook is installed, leaving its refreshToken empty. The hook populates
the token from pass before each connect() call.

Includes credential migration from Thunderbird's password manager,
an informational popup, and build/packaging support.
@thardeck thardeck force-pushed the add_support_for_thunderbird branch from f48a376 to a877887 Compare April 26, 2026 15:22
@thardeck
Copy link
Copy Markdown
Author

thardeck commented Apr 26, 2026

I have reported the mentioned Thunderbird issue quite some time ago but did not really got feedback.

In general the OAuth part in Thunderbird is the most error prone, I have only got it reliably working with this extension when enabling "Offline Support" for the calendars.
But after setting it up with this option, it works quite well for me.

The branch has been reworked since the last push. I have also adapted the original pull request description with the new changes, to not be confusing.

Any feedback is welcome.

Here is a summary of the changes.


Password store layout

The old layout used protocol-specific subdirectories and per-account OAuth files:

imap/mail.example.com.gpg
smtp/mail.example.com.gpg
oauth/mail/user@example.com.gpg
oauth/caldav/user@example.com.gpg
oauth/carddav/user@example.com.gpg

This has been replaced with a flat thunderbird/ directory and {protocol}-{hostname} naming:

thunderbird/imap-mail.example.com.gpg
thunderbird/smtp-mail.example.com.gpg
thunderbird/oauth-accounts.google.com.gpg
thunderbird/https-sso.example.com.gpg

The old layout required scope-based matching logic (scopeToRequiredService, fileSupportsRequiredScope) to pick the right OAuth file per service. Thunderbird stores a single OAuth token per server internally — the same token is used for mail, CalDAV, and CardDAV — so having per-service files was over-engineered and the source of the token permission problem I described in my last comment (Google issuing a calendar-only token if CalDAV triggers the OAuth flow first). The new layout follows Thunderbird's own model: one token per provider hostname.

Migration

Migration is no longer automatic. A "Migrate credentials from Thunderbird" button is now available in the Add-on Preferences page (visible only in Thunderbird). Clicking it reads Thunderbird's password manager via the experiment API, translates each entry to the new thunderbird/ path layout, skips any entry that already exists in pass, and saves the rest. The result (migrated/skipped/failed counts) is shown inline.

This addresses the concern raised in the review about unprompted credential dumps. The user explicitly initiates migration and can see what happened.

CalDAV startup OAuth windows (TB bug 2008995)

The old implementation tried to close OAuth windows that appeared before the extension was initialised. This was fragile and caused issues.

The new implementation adds a hook on OAuth2.prototype.connect. CalDAV creates its shared OAuth2 instance before our OAuth2Module.getRefreshToken hook is called, leaving this.refreshToken empty. The connect hook runs on every connect() call and populates this.refreshToken from pass before Thunderbird's own check, so Thunderbird never needs to open an OAuth window for an existing token.

For windows that do open before startupReady is set (a narrow race at first startup), the handler now returns early with a debug log instead of trying to close the window. If no token is in pass yet, the user completes the OAuth flow normally; the resulting token is saved to pass by setRefreshToken and no window appears on subsequent startups.

The recommended workaround for first-run CalDAV windows remains enabling offline caching (Calendar → Properties → Offline Support → Keep a local copy).

Notification in OAuth browser window

nsIAlertsService.showAlertNotification is not available in current Thunderbird. The notification is now a <div id="browserpass-hint"> injected before #requestFrame in the OAuth browser window. The banner is reused (element updated) rather than stacked.

YubiKey / concurrent CalDAV syncs

When multiple CalDAV calendars sync at the same time, each triggered a separate getRefreshToken call and therefore a separate GPG decrypt — requiring multiple YubiKey touches for the same token. A pendingLookups set now ensures only one GPG decrypt runs at a time per token key. Parallel callers spin-wait until the result is available and reuse it.

MV3 background script lifecycle

The background script unloads after ~30 s of inactivity. Two failure modes were addressed:

  1. Background dies during a decryptawaitSync now accepts an abortFn parameter. waitForCredentials passes () => requestListenerCount === 0 so the spin exits immediately when the conduit closes, rather than waiting for the full 60 s timeout. The caller retries once the background revives.

  2. Token loss after OAuth dance — After a user completes an OAuth flow, setRefreshToken queues the token for storage to pass. The OAuth dance takes minutes, during which the background has long since unloaded. The retry loop now calls wakeUpExtension() on each tick to actively revive the background rather than waiting passively.

The awaitSync timeout was also increased from 30 s to 60 s to give more headroom for YubiKey touch.

Other changes

  • importModule() compatibility shim removed; ChromeUtils.importESModule is used directly (Thunderbird 128+ only, matching strict_min_version in the manifest).
  • saveCredentialToPass() wrapper removed; call sites use waitForPasswordStore() directly.
  • Dead saveOAuthToken message listener removed from background.js.
  • request.account?.extraAuthParams corrected to request.oauth?.extraAuthParams.
  • Services IIFE removed; Services is available directly in this context.
  • README and PRIVACY.md updated to reflect the new pass layout and explicit migration.

@erayd
Copy link
Copy Markdown
Collaborator

erayd commented Apr 30, 2026

Hi @thardeck - sorry it's taken me so long to review this PR! It's clear you've put a lot of work into it.

Unfortunately, I don't think that this is really something that belongs in Browserpass - it's well outside the scope of what Browserpass is intended to be, which is a web-browser interface to a pass-style repository of gpg-encrypted website credentials. This is exacerbated somewhat by the esoteric nature of the functionality that this particular PR is intended to implement, for which I suspect the ultimate user base will be extremely small - and quite possibly limited to just yourself.

With that said, as Browserpass is a project that tends to change fairly infrequently, it would likely be viable for you to maintain this patchset as an independent fork. I'm happy for us to add a link to the project README that will point any users who want this functionality in your direction (let us know if you'd like that).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants