Syncing Gmail with mbsync using OAuth2

I wholeheartedly dislike GMail (ethically, technically, and UX), and for my personal email I have always run my own server. But for work email I don’t have a choice. I am using isync/mbsync to make it usable for me and mutt. Until now I’ve used a Google app password to authenticate, but they are a security nightmare.

OAuth2 is a better way. Sadly the interwebs have only scarce, outdated, or buggy recipes, so I finally spent the better part of an afternoon and moved OAuth2.

mbsync has no native XOAUTH2

mbsync reaches OAuth2 through Cyrus SASL, so it needs a SASL plugin providing the XOAUTH2 mechanism. Debian’s libsasl2-modules does not have one.

The package you want is sasl-xoauth2. In Debian it is currently in experimental only, at 0.20-1, last uploaded in May 2023. It also has a release critical bug #1119802: token refresh fails when linked against libcurl >= 8.17. That is precisely the code path this whole setup depends on, so you get a setup that works for exactly one hour and then dies. (For the record: That affects the Fedora package as well, it doesn’t have that patch either.)

I have NMU’d the current upstream release 0.27 with the upstream fix cherry-picked (it’s not in a release yet). It sits in DELAYED/7, so it should land in experimental in a week. Until then, feel free to use my Debian testing build.

Check that the plugin actually registered before touching anything else:

/usr/sbin/saslpluginviewer -c | grep -A1 sasl-xoauth2

should show “Plugin sasl-xoauth2 [loaded]“.

Borrowing an OAuth client

Here is the most annoying part: You need a Google Cloud project to authenticate against. Thunderbird and K-9 do not ask you to create one, they ship their own client credentials, hardcoded in the source. Thunderbird’s live in OAuth2Providers.sys.mjs, marked builtIn: true, client secret and all. That is not a leak: an installed app is a public client under RFC 8252 §8.5 and cannot keep a secret. Every Thunderbird user on the planet shares that identity, and the only per-user artifact is the refresh token.

mbsync has no such vendor identity, so you must supply a client. Creating one means a Cloud project, an OAuth consent screen and a “Desktop app” credential, which is a preposterous amount of ceremony for reading your own mail. In my case it’s impossible anyway, since my Workspace account does not allow me create projects.

So I borrowed Thunderbird’s despite the big warning there. Caveats: the consent screen will say “Thunderbird”, and a Workspace admin filtering third-party apps by client ID can break it. If you can create a project, make the consent screen Internal: that skips Google’s verification entirely and avoids the seven day refresh token expiry that External clients in “Testing” get imposed on.

Acquire token

Create a /etc/sasl-xoauth2.conf file, with that literal contents:

{
  "client_id": "PLACEHOLDER",
  "client_secret": "PLACEHOLDER"
}

Without that, the plugin refuses to register, silently (it is written with system-level MTAs in mind). The placeholder values are on purpose: the per-user token file overrides them, so this system-wide file stays user-agnostic and holds no secrets.

Then the user token file, which holds the real credentials plus the refresh token. Start with the public Thunderbird one:

mkdir -p ~/.config/sasl-xoauth2 && chmod 700 ~/.config/sasl-xoauth2
cat > ~/.config/sasl-xoauth2/gmail.json <<'EOF'
{
  "client_id": "406964657835-aq8lmia8j95dhl1a2bvharmfk3t1hgqj.apps.googleusercontent.com",
  "client_secret": "kSmqreRr0qwBWJgbf5Y-PjSU"
}
EOF
chmod 600 ~/.config/sasl-xoauth2/gmail.json

Then request an OAuth2 token:

TOKENS=~/.config/sasl-xoauth2/gmail.json
sasl-xoauth2-tool get-token gmail "$TOKENS" \
    --overwrite-existing-token \
    --client-id="$(jq -r .client_id "$TOKENS")" \
    --client-secret="$(jq -r .client_secret "$TOKENS")" \
    --scope='https://mail.google.com/'

That prints a URL, you approve it in a browser, and the tool writes the tokens to the above file.

Warning: --overwrite-existing-token is critical here. Without it the tool starts from an empty dict and writes only the token fields, silently dropping the two client keys you just put in the file. Refresh then falls back to the PLACEHOLDER values and fails an hour later.

After that, verify that refreshing the token actually works:

sasl-xoauth2-tool test-token-refresh ~/.config/sasl-xoauth2/gmail.json

mbsync configuration

Write/add this into ~/.config/isyncrc, replacing User (your email address), Pass (path to the above token file) and the gmail-local store paths:

IMAPAccount gmail
Host imap.gmail.com
Port 993
User myuser@example.com
# yes, this needs full path, ~ does not work here
Pass /home/myuser/.config/sasl-xoauth2/gmail.json
TLSType IMAPS
AuthMechs XOAUTH2

IMAPStore gmail-remote
Account gmail

MaildirStore gmail-local
Path ~/.cache/mail/a/
Inbox ~/.cache/mail/a/INBOX

Channel gmail
Far :gmail-remote:
Near :gmail-local:
Patterns * ![Gmail]*
Create Both
Expunge Both
SyncState *

Note: mbsync does not call sasl-xoauth2-tool at sync time. The tool is a one-shot bootstrap; mbsync dlopen()s the SASL plugin which does its own refresh: mbsynclibsasl2libsasl-xoauth2.solibcurl → accounts.google.com.

Which is exactly why that bug mentioned above breaks every sync after the first hour. You only need the plugin plus its config wherever mbsync runs. The Python tool can live elsewhere.