Connecting Gmail
A comprehensive guide to configuring NeoMutt with Gmail using OAuth2 authentication and GPG encryption. This step-by-step tutorial covers Google Cloud Console setup, token management, and NeoMutt configuration for the post-App Password era.
Background
I have successfully configured NeoMutt to work with two separate email accounts:
- twine (primary): Uses maildir
- biaget (occasional): Uses IMAP connection
However, setting up NeoMutt with my soundfreaq.com Gmail-hosted account proved challenging due to Google's security restrictions. After App Passwords were deprecated and OAuth2 became mandatory, the landscape changed significantly.
What I've Tried
- SeniorMars - Accessing Gmail with NeoMutt – App password not enabled by admin; deprecated
- Red Hat - Mutt Email OAuth2 – Uses
mutt_oauth2.py, but unclear why GPG keys are required - Oreate AI - Step-by-Step Guide – Downloaded
client_secret_*.jsonto~/Downloads, but unsure how to integrateclient_idandclient_secretintoneomuttrc - YouTube - NeoMutt + vygrant – Introduces vygrant as alternative to
mutt_oauth2, but its role in the workflow is unclear
Overview
Setting up NeoMutt with Gmail in the post-App Password era is a rite of passage for terminal users. This guide consolidates everything needed to configure NeoMutt + Gmail (soundfreaq.com) using OAuth2 and GPG.
The setup involves four phases:
| Phase | Topic | Goal |
|---|---|---|
| 1 | Google Cloud Console | Create OAuth2 credentials and publish the app |
| 2 | GPG Encryption | Secure your tokens locally |
| 3 | OAuth2 Handshake | Obtain the refresh token |
| 4 | NeoMutt Configuration | Wire everything together |
Phase 1: Google Cloud Console Setup
This is where most people get stuck due to Google's "Verification" warnings.
1.1 Create Your Project
- Go to Google Cloud Console
- Create a new project (e.g., "MyNeoMutt")
- Enable the Gmail API
1.2 Create OAuth2 Credentials
Navigate to APIs & Services > Credentials
Click Create credentials → OAuth client ID
Select Desktop App as the application type
Download the JSON file and save it to:
$HOME/.config/secrets/client_secret_1091889733448-e3tfetj4pg7nfambq2cibcpbutehqp4k.apps.googleusercontent.com.jsonQ: What is this JSON file and what role does it play?
A: This file contains your OAuth2 application credentials—specifically the
client_idandclient_secret. These identify your NeoMutt installation to Google's OAuth2 server. During Phase 3, you'll provide both values tomutt_oauth2.py, which exchanges them (along with your browser grant) for a refresh token. Think of it as your app's "username and password" to Google's API.Q: Should I version-control this file? Is it dangerous if leaked?
A: No, never version-control this file. While the
client_idis public, theclient_secretis sensitive. If leaked, an attacker could impersonate your OAuth2 application. Add*.jsonto~/.config/secrets/.gitignore. If accidentally leaked, revoke it in Google Cloud Console and generate new credentials.
1.3 Configure OAuth Consent Screen
- Navigate to APIs & Services > OAuth consent screen
- The Problem: By default, your app is in "Testing" mode, which causes "Access blocked" errors
- The Solution (choose one, but publishing is recommended):
- Add
alowree@soundfreaq.comto Test Users, OR - Navigate to Publishing status and click Publish App
- Add
Note: Google will warn about "Verification." You can ignore this. Verification is only required if you want other people to use your app. For personal use, an "Unverified" production app works indefinitely.
Q: Why does publishing matter? What's the difference between Testing and Published mode?
A: Google requires OAuth app verification to protect users from malicious applications, but this is designed for public apps. For personal use only:
| Mode | Token Behavior |
|---|---|
| Testing | Refresh tokens expire every 7 days, forcing weekly re-authorization |
| Published (Unverified) | Tokens remain valid indefinitely for your single user account |
| Published (Verified) | Required only when accessing data for multiple external users |
Publishing as "Unverified" bypasses the 7-day token expiration without needing Google's security review.
Q: What happens if I skip both options (no test users, no publishing)?
A: Your app remains in "Testing" mode with no authorized users. When attempting OAuth2 authorization:
- You'll encounter "Access blocked" or Error 403: access_denied
- Google will refuse to issue a refresh token
- The OAuth2 handshake cannot complete
To recover, return to Google Cloud Console and either add your email as a test user or publish the app.
Phase 2: GPG Setup
The OAuth2 token file contains sensitive credentials. GPG encryption ensures they aren't stored in plain text.
Q: Is the OAuth2 token file the same as the JSON file from 1.2?
A: No, they are two different files:
| File | Created In | Contents | Purpose |
|---|---|---|---|
client_secret_*.json | Phase 1.2 (Google Cloud Console) | client_id and client_secret | Identifies your OAuth2 application to Google |
.tokens (e.g., soundfreaq.tokens) | Phase 3 (OAuth2 handshake) | access_token, refresh_token, expiry | Stores your personal refresh token |
The JSON file is your app's credentials (used during authorization). The .tokens file is generated by mutt_oauth2.py after browser authorization—it contains your personal refresh token that NeoMutt uses daily.
Q: Why is GPG encryption necessary?
A: The token file contains a refresh token—a long-lived credential that can generate new access tokens. If stored in plain text:
- Theft risk: Malware or attackers can read your token and gain full Gmail access
- No password protection: Tokens remain valid until revoked (unlike passwords you can change)
- Bearer token: Whoever holds the token "bears" the identity—no additional auth needed
GPG encryption ensures that even if someone steals the .tokens file, they cannot use it without your GPG passphrase.
2.1 Generate a GPG Key
gpg --full-generate-keyFollow the prompts with these settings:
| Setting | Value |
|---|---|
| Key Type | RSA and RSA (default) |
| Keysize | 3072 or 4096 |
| Expiration | 0 (doesn't expire) or your preference |
| Real Name | alowree |
alowree@soundfreaq.com | |
| Comment | passphrase is your cell number |
| Passphrase | Enter and confirm a strong passphrase |
Q: Is generating a GPG key the same as "GPG encryption"?
A: Not exactly—they're related but distinct:
| Step | What It Does | Analogy |
|---|---|---|
| Generate GPG key (2.1) | Creates a key pair (public + private) for encryption/decryption | Buying a safe and its key |
| GPG encryption | Uses the key to encrypt the .tokens file | Locking your tokens inside the safe |
You need the GPG key first. The mutt_oauth2.py script will automatically encrypt the .tokens file using your GPG public key during Phase 3.
2.2 Configure GPG TTY
Open your ~/.zshrc (or ~/.bashrc) and add:
export GPG_TTY=$(tty)Q: Why is GPG_TTY needed?
A: Without this, GPG cannot prompt for your passphrase when NeoMutt runs in the background. This tells GPG which terminal to use for interactive prompts.
2.3 Understanding the GPG Passphrase
Q: With GPG_TTY set, why doesn't GPG ask for the passphrase every time?
A: The passphrase protects your private GPG key. Here's how it works:
- First use: When NeoMutt starts and needs to decrypt the token file,
gpg-agentprompts for your passphrase - Cached in memory: After correct entry,
gpg-agentstores the unlocked private key in RAM for a configurable timeout (default: 10 minutes) - Subsequent uses: During the cache window, GPG doesn't ask again—the agent handles decryption silently
- Session persistence: This is why you might only see the prompt once per terminal session
Security model:
- The passphrase itself is never saved to disk
- It lives only in volatile memory via
gpg-agent - Once the agent cache expires or you log out, the passphrase is forgotten
- The encrypted
.tokensfile remains secure on disk
Q: Does entering the passphrase mean I can access the encrypted token file?
A: Yes—entering the passphrase proves you're authorized. GPG decrypts the file in memory and passes the token to NeoMutt. The decrypted token is never written to disk.
Phase 3: OAuth2 Handshake
NeoMutt uses an external Python script to handle the "OAuth2 dance."
3.1 Understanding Refresh Tokens
Q: What is a Refresh Token and why do we need it?
A: OAuth2 uses a two-token system for security:
| Token Type | Lifespan | Purpose |
|---|---|---|
| Access Token | ~1 hour | Grants access to Gmail API; used for every email operation |
| Refresh Token | Indefinite | Used only to obtain new access tokens when they expire |
Why this design?
- If an access token is stolen, it expires quickly (1 hour max)
- The refresh token never directly accesses Gmail—it only generates new access tokens
- You can revoke the refresh token anytime (via Google Account settings) to cut off all access
- NeoMutt automatically runs the OAuth2 script in the background to refresh tokens before they expire
3.2 Download the Script
curl -O https://raw.githubusercontent.com/neomutt/neomutt/master/contrib/oauth2/mutt_oauth2.py
chmod +x mutt_oauth2.py
mv mutt_oauth2.py ~/.local/bin/3.3 Authorize Your Account
~/.local/bin/mutt_oauth2.py --authorize --email alowree@soundfreaq.com ~/.config/secrets/soundfreaq.tokensThe script will prompt for:
| Prompt | Value |
|---|---|
| OAuth2 registration | google |
| Preferred OAuth2 flow | localhostauthcode |
| Client ID | (from your downloaded JSON file) |
| Client Secret | (from your downloaded JSON file) |
It will then provide a URL. Copy and paste this into your browser.
3.4 Complete Browser Authorization
Log in with your
alowree@soundfreaq.comaccountYou'll likely see a "Google hasn't verified this app" warning. Click Advanced → Go to [Project Name] (unsafe)
Grant permission
Error Message You Might See:
Access blocked: NeoMutt has not completed the Google verification process NeoMutt has not completed the Google verification process. The app is currently being tested, and can only be accessed by developer-approved testers. Error 403: access_deniedThis message appears if you haven't published the app (Phase 1.3). Return to Google Cloud Console and publish the app.
After granting permission, you'll be redirected to a
localhostpageCopy the
code=value from the browser's address bar and paste it back into your terminal
Phase 3 Summary: What You Should Have Now
Q: What should I have after correctly completing Phase 3?
A: If Phase 3 completed successfully, you should have:
| Item | Location | Status |
|---|---|---|
| Encrypted token file | ~/.config/secrets/soundfreaq.tokens (GPG-encrypted) | Contains your refresh token |
| OAuth2 authorization | Google Cloud Console | Your app has exchanged client_id + client_secret + auth code for a refresh token |
| GPG decryption working | gpg-agent running with cached passphrase | NeoMutt can decrypt tokens silently |
| Ready for Phase 4 | — | Only NeoMutt configuration remains |
Verification: Run gpg -d ~/.config/secrets/soundfreaq.tokens — if it prompts for your passphrase and displays JSON with refresh_token, you're ready.
Phase 4: NeoMutt Configuration
Update your account configuration file (e.g., ~/.config/neomutt/accounts/soundfreaq):
# Identity
set from = "alowree@soundfreaq.com"
set real_name = "Alowree Xu - Soundfreaq"
# OAuth2 Core Config
set imap_user = "alowree@soundfreaq.com"
set imap_authenticators = "oauthbearer"
set smtp_authenticators = "oauthbearer"
# Token Script (Use absolute paths, not tilde)
set imap_oauth_refresh_command = "/Users/alowree/.local/bin/mutt_oauth2.py /Users/alowree/.config/secrets/soundfreaq.tokens"
set smtp_oauth_refresh_command = "/Users/alowree/.local/bin/mutt_oauth2.py /Users/alowree/.config/secrets/soundfreaq.tokens"
# Connection Strings (Note the :oauth2 flag for SMTP)
set folder = "imaps://imap.gmail.com:993/"
set spool_file = "+INBOX"
set smtp_url = "smtps://alowree@soundfreaq.com:oauth2@smtp.gmail.com:465/"
# Gmail Folder Mapping
set postponed = "+[Gmail]/Drafts"
set record = "+[Gmail]/Sent Mail"
set trash = "+[Gmail]/Trash"
# Sidebar / Mailboxes
unmailboxes *
mailboxes "+INBOX" "+[Gmail]/Sent Mail" "+[Gmail]/Drafts" "+[Gmail]/Trash"Verification & Testing
After completing all four phases, verify your setup:
# Test IMAP connection
neomutt -f "imaps://imap.gmail.com:993/"
# Or simply launch NeoMutt and check if emails load
neomuttIf successful, you should see your Gmail inbox without any authentication errors.
Troubleshooting
Forgot GPG Passphrase
If you forget the GPG passphrase:
- Delete the
.tokensfile:rm ~/.config/secrets/soundfreaq.tokens - Delete the GPG key:
gpg --delete-secret-keys alowree@soundfreaq.com - Repeat Phase 2 and Phase 3
Silent Login Behavior
With GPG_TTY configured:
- Your OS may ask for the GPG passphrase once when you open NeoMutt
- After that, it stays in
gpg-agentmemory for the rest of your session - Subsequent NeoMutt launches within the cache window won't prompt again
Token Expiration Issues
If tokens stop working:
- Verify the app is Published in Google Cloud Console (not in Testing mode)
- Re-run the authorization command in Phase 3.3
- Check that
gpg-agentis running:gpgconf --list-dirs agent-socket
Alternative: Why "vygrant"?
vygrant is a modern wrapper/alternative to mutt_oauth2.py. It:
- Automates token management
- Integrates with system keyrings (Apple Keychain, GNOME Keyring)
- Eliminates manual GPG file handling
Recommendation
If you're comfortable with Python scripts, stick with the mutt_oauth2.py method because:
- It's the standard approach documented by NeoMutt
- More transparent for troubleshooting
- Better understanding of what's happening under the hood
Use vygrant if you prefer seamless keychain integration and don't need to understand the underlying mechanics.
Summary Checklist
- Google Cloud project created with Gmail API enabled
- OAuth2 credentials downloaded to
~/.config/secrets/ - OAuth consent screen published (not in Testing mode)
- GPG key generated with passphrase
GPG_TTY=$(tty)added to shell configmutt_oauth2.pydownloaded and made executable- Refresh token obtained via browser authorization
- NeoMutt account config updated with OAuth2 settings
- Test connection with
neomutt -f imaps://imap.gmail.com:993/