feat: oAuth with Gmail (#415)

Drew Smirnoff created

Change summary

config/config.go                                                 |   5 
config/oauth.go                                                  |  85 
config/oauth_script.py                                           | 370 ++
docs/docs/assets/setup-guides/gmail/oauth-add-account.png        |   0 
docs/docs/assets/setup-guides/gmail/oauth-browser-auth.png       |   0 
docs/docs/assets/setup-guides/gmail/oauth-consent-screen.png     |   0 
docs/docs/assets/setup-guides/gmail/oauth-create-credentials.png |   0 
docs/docs/assets/setup-guides/gmail/oauth-create-project.png     |   0 
docs/docs/assets/setup-guides/gmail/oauth-enable-gmail-api.png   |   0 
docs/docs/setup-guides/gmail.md                                  | 127 
fetcher/fetcher.go                                               |  15 
fetcher/xoauth2.go                                               |  34 
main.go                                                          |  69 
sender/sender.go                                                 |  29 
tui/login.go                                                     |  70 
tui/messages.go                                                  |  12 
16 files changed, 803 insertions(+), 13 deletions(-)

Detailed changes

config/config.go 🔗

@@ -33,6 +33,9 @@ type Account struct {
 	SMIMECert          string `json:"smime_cert,omitempty"`            // Path to the public certificate PEM
 	SMIMEKey           string `json:"smime_key,omitempty"`             // Path to the private key PEM
 	SMIMESignByDefault bool   `json:"smime_sign_by_default,omitempty"` // Whether to enable S/MIME signing by default
+
+	// OAuth2 settings
+	AuthMethod string `json:"auth_method,omitempty"` // "password" (default) or "oauth2"
 }
 
 // MailingList represents a named group of email addresses.
@@ -186,6 +189,7 @@ func LoadConfig() (*Config, error) {
 		SMIMECert          string `json:"smime_cert,omitempty"`
 		SMIMEKey           string `json:"smime_key,omitempty"`
 		SMIMESignByDefault bool   `json:"smime_sign_by_default,omitempty"`
+		AuthMethod         string `json:"auth_method,omitempty"`
 	}
 	type diskConfig struct {
 		Accounts      []rawAccount  `json:"accounts"`
@@ -239,6 +243,7 @@ func LoadConfig() (*Config, error) {
 			SMIMECert:          rawAcc.SMIMECert,
 			SMIMEKey:           rawAcc.SMIMEKey,
 			SMIMESignByDefault: rawAcc.SMIMESignByDefault,
+			AuthMethod:         rawAcc.AuthMethod,
 		}
 
 		if rawAcc.Password != "" {

config/oauth.go 🔗

@@ -0,0 +1,85 @@
+package config
+
+import (
+	_ "embed"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+)
+
+//go:embed oauth_script.py
+var embeddedOAuthScript []byte
+
+// IsOAuth2 returns true if the account uses OAuth2 authentication.
+func (a *Account) IsOAuth2() bool {
+	return a.AuthMethod == "oauth2"
+}
+
+// OAuthScriptPath returns the path to the Gmail OAuth2 Python helper script.
+// The script is embedded in the binary and extracted to ~/.config/matcha/oauth/
+// on first use.
+func OAuthScriptPath() (string, error) {
+	dir, err := configDir()
+	if err != nil {
+		return "", err
+	}
+
+	scriptDir := filepath.Join(dir, "oauth")
+	scriptPath := filepath.Join(scriptDir, "gmail_oauth.py")
+
+	// Always overwrite with the embedded version to stay in sync with the binary
+	if err := os.MkdirAll(scriptDir, 0700); err != nil {
+		return "", fmt.Errorf("could not create oauth directory: %w", err)
+	}
+	if err := os.WriteFile(scriptPath, embeddedOAuthScript, 0700); err != nil {
+		return "", fmt.Errorf("could not extract oauth script: %w", err)
+	}
+
+	return scriptPath, nil
+}
+
+// GetOAuth2Token retrieves a fresh OAuth2 access token for the account by
+// invoking the Python helper script. The script handles token refresh
+// automatically.
+func GetOAuth2Token(email string) (string, error) {
+	script, err := OAuthScriptPath()
+	if err != nil {
+		return "", err
+	}
+
+	cmd := exec.Command("python3", script, "token", email)
+	cmd.Stderr = os.Stderr
+	out, err := cmd.Output()
+	if err != nil {
+		return "", fmt.Errorf("oauth2 token retrieval failed: %w", err)
+	}
+
+	token := strings.TrimSpace(string(out))
+	if token == "" {
+		return "", fmt.Errorf("oauth2: empty access token returned")
+	}
+
+	return token, nil
+}
+
+// RunOAuth2Flow launches the OAuth2 authorization flow by invoking the Python
+// helper script. It opens the user's browser for authorization.
+// clientID and clientSecret are optional — if empty, the script uses stored credentials.
+func RunOAuth2Flow(email, clientID, clientSecret string) error {
+	script, err := OAuthScriptPath()
+	if err != nil {
+		return err
+	}
+
+	args := []string{script, "auth", email}
+	if clientID != "" && clientSecret != "" {
+		args = append(args, "--client-id", clientID, "--client-secret", clientSecret)
+	}
+
+	cmd := exec.Command("python3", args...)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	return cmd.Run()
+}

config/oauth_script.py 🔗

@@ -0,0 +1,370 @@
+#!/usr/bin/env python3
+"""
+Gmail OAuth2 helper for Matcha email client.
+
+Handles the full OAuth2 flow for Gmail:
+  - Browser-based authorization
+  - Localhost callback server for auth code capture
+  - Token exchange and refresh
+  - Secure token storage in ~/.config/matcha/oauth_tokens/
+
+Usage:
+  gmail_oauth.py auth   <email> [--client-id ID --client-secret SECRET]
+  gmail_oauth.py token  <email>
+  gmail_oauth.py revoke <email>
+
+The 'auth' command initiates the OAuth2 flow, opening a browser.
+The 'token' command prints a fresh access token to stdout (refreshing if needed).
+The 'revoke' command deletes stored tokens for the given account.
+"""
+
+import argparse
+import hashlib
+import http.server
+import json
+import os
+import secrets
+import sys
+import threading
+import time
+import urllib.parse
+import urllib.request
+import webbrowser
+
+# Gmail OAuth2 scopes for IMAP and SMTP access
+SCOPES = [
+    "https://mail.google.com/",
+]
+
+TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
+AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"
+REVOKE_ENDPOINT = "https://oauth2.googleapis.com/revoke"
+
+REDIRECT_PORT = 8189
+REDIRECT_URI = f"http://localhost:{REDIRECT_PORT}"
+
+
+def get_token_dir():
+    """Return the token storage directory, creating it if needed."""
+    home = os.path.expanduser("~")
+    token_dir = os.path.join(home, ".config", "matcha", "oauth_tokens")
+    os.makedirs(token_dir, mode=0o700, exist_ok=True)
+    return token_dir
+
+
+def token_file_for(email):
+    """Return the token file path for a given email address."""
+    safe_name = hashlib.sha256(email.encode()).hexdigest()[:16]
+    return os.path.join(get_token_dir(), f"{safe_name}.json")
+
+
+def load_tokens(email):
+    """Load stored tokens for the given email, or return None."""
+    path = token_file_for(email)
+    if not os.path.exists(path):
+        return None
+    with open(path, "r") as f:
+        return json.load(f)
+
+
+def save_tokens(email, tokens):
+    """Save tokens to disk with restrictive permissions."""
+    path = token_file_for(email)
+    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+    with os.fdopen(fd, "w") as f:
+        json.dump(tokens, f, indent=2)
+
+
+def load_client_credentials():
+    """Load OAuth2 client credentials from ~/.config/matcha/oauth_client.json."""
+    home = os.path.expanduser("~")
+    cred_path = os.path.join(home, ".config", "matcha", "oauth_client.json")
+    if not os.path.exists(cred_path):
+        return None, None
+    with open(cred_path, "r") as f:
+        data = json.load(f)
+    return data.get("client_id"), data.get("client_secret")
+
+
+def save_client_credentials(client_id, client_secret):
+    """Save OAuth2 client credentials to ~/.config/matcha/oauth_client.json."""
+    home = os.path.expanduser("~")
+    cred_path = os.path.join(home, ".config", "matcha", "oauth_client.json")
+    fd = os.open(cred_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+    with os.fdopen(fd, "w") as f:
+        json.dump({"client_id": client_id, "client_secret": client_secret}, f, indent=2)
+
+
+def exchange_code(code, client_id, client_secret):
+    """Exchange an authorization code for tokens."""
+    data = urllib.parse.urlencode({
+        "code": code,
+        "client_id": client_id,
+        "client_secret": client_secret,
+        "redirect_uri": REDIRECT_URI,
+        "grant_type": "authorization_code",
+    }).encode()
+
+    req = urllib.request.Request(TOKEN_ENDPOINT, data=data, method="POST")
+    req.add_header("Content-Type", "application/x-www-form-urlencoded")
+
+    with urllib.request.urlopen(req) as resp:
+        return json.loads(resp.read().decode())
+
+
+def refresh_access_token(refresh_token, client_id, client_secret):
+    """Use a refresh token to get a new access token."""
+    data = urllib.parse.urlencode({
+        "refresh_token": refresh_token,
+        "client_id": client_id,
+        "client_secret": client_secret,
+        "grant_type": "refresh_token",
+    }).encode()
+
+    req = urllib.request.Request(TOKEN_ENDPOINT, data=data, method="POST")
+    req.add_header("Content-Type", "application/x-www-form-urlencoded")
+
+    with urllib.request.urlopen(req) as resp:
+        return json.loads(resp.read().decode())
+
+
+def revoke_token(token):
+    """Revoke an OAuth2 token."""
+    data = urllib.parse.urlencode({"token": token}).encode()
+    req = urllib.request.Request(REVOKE_ENDPOINT, data=data, method="POST")
+    req.add_header("Content-Type", "application/x-www-form-urlencoded")
+
+    try:
+        with urllib.request.urlopen(req) as resp:
+            return resp.status == 200
+    except urllib.error.HTTPError:
+        return False
+
+
+class OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
+    """HTTP handler that captures the OAuth2 callback."""
+
+    auth_code = None
+    error = None
+
+    def do_GET(self):
+        parsed = urllib.parse.urlparse(self.path)
+        params = urllib.parse.parse_qs(parsed.query)
+
+        if "code" in params:
+            OAuthCallbackHandler.auth_code = params["code"][0]
+            self.send_response(200)
+            self.send_header("Content-Type", "text/html")
+            self.end_headers()
+            self.wfile.write(b"""
+            <html><body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
+            <h2>Authorization successful!</h2>
+            <p>You can close this window and return to Matcha.</p>
+            </body></html>
+            """)
+        elif "error" in params:
+            OAuthCallbackHandler.error = params["error"][0]
+            self.send_response(400)
+            self.send_header("Content-Type", "text/html")
+            self.end_headers()
+            self.wfile.write(f"""
+            <html><body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
+            <h2>Authorization failed</h2>
+            <p>Error: {params['error'][0]}</p>
+            </body></html>
+            """.encode())
+        else:
+            self.send_response(404)
+            self.end_headers()
+
+    def log_message(self, format, *args):
+        """Suppress HTTP server logs."""
+        pass
+
+
+def do_auth(email, client_id, client_secret):
+    """Run the full OAuth2 authorization flow."""
+    # Generate PKCE code verifier/challenge for extra security
+    state = secrets.token_urlsafe(32)
+
+    auth_params = urllib.parse.urlencode({
+        "client_id": client_id,
+        "redirect_uri": REDIRECT_URI,
+        "response_type": "code",
+        "scope": " ".join(SCOPES),
+        "access_type": "offline",
+        "prompt": "consent",
+        "state": state,
+        "login_hint": email,
+    })
+
+    auth_url = f"{AUTH_ENDPOINT}?{auth_params}"
+
+    # Reset handler state
+    OAuthCallbackHandler.auth_code = None
+    OAuthCallbackHandler.error = None
+
+    # Start local HTTP server for callback
+    server = http.server.HTTPServer(("localhost", REDIRECT_PORT), OAuthCallbackHandler)
+    server.timeout = 120  # 2 minute timeout
+
+    print(f"Opening browser for Gmail authorization...", file=sys.stderr)
+    print(f"If the browser doesn't open, visit this URL:", file=sys.stderr)
+    print(f"  {auth_url}", file=sys.stderr)
+
+    webbrowser.open(auth_url)
+
+    # Wait for the callback
+    while OAuthCallbackHandler.auth_code is None and OAuthCallbackHandler.error is None:
+        server.handle_request()
+
+    server.server_close()
+
+    if OAuthCallbackHandler.error:
+        print(f"Authorization error: {OAuthCallbackHandler.error}", file=sys.stderr)
+        sys.exit(1)
+
+    code = OAuthCallbackHandler.auth_code
+    print("Authorization code received, exchanging for tokens...", file=sys.stderr)
+
+    # Exchange code for tokens
+    token_response = exchange_code(code, client_id, client_secret)
+
+    if "error" in token_response:
+        print(f"Token exchange error: {token_response['error']}", file=sys.stderr)
+        sys.exit(1)
+
+    # Store tokens with metadata
+    tokens = {
+        "access_token": token_response["access_token"],
+        "refresh_token": token_response.get("refresh_token"),
+        "expires_at": int(time.time()) + token_response.get("expires_in", 3600),
+        "token_type": token_response.get("token_type", "Bearer"),
+        "email": email,
+    }
+
+    save_tokens(email, tokens)
+    save_client_credentials(client_id, client_secret)
+
+    print("Authorization complete! Tokens saved.", file=sys.stderr)
+    # Print the access token to stdout for immediate use
+    print(tokens["access_token"])
+
+
+def do_token(email):
+    """Get a fresh access token, refreshing if needed."""
+    tokens = load_tokens(email)
+    if tokens is None:
+        print("No tokens found. Run 'auth' first.", file=sys.stderr)
+        sys.exit(1)
+
+    # Check if token is expired (with 5 minute buffer)
+    if time.time() >= tokens.get("expires_at", 0) - 300:
+        client_id, client_secret = load_client_credentials()
+        if not client_id or not client_secret:
+            print("No client credentials found. Run 'auth' first.", file=sys.stderr)
+            sys.exit(1)
+
+        refresh_token = tokens.get("refresh_token")
+        if not refresh_token:
+            print("No refresh token available. Run 'auth' again.", file=sys.stderr)
+            sys.exit(1)
+
+        try:
+            new_tokens = refresh_access_token(refresh_token, client_id, client_secret)
+        except urllib.error.HTTPError as e:
+            print(f"Token refresh failed: {e}", file=sys.stderr)
+            sys.exit(1)
+
+        tokens["access_token"] = new_tokens["access_token"]
+        tokens["expires_at"] = int(time.time()) + new_tokens.get("expires_in", 3600)
+        # Refresh tokens may be rotated
+        if "refresh_token" in new_tokens:
+            tokens["refresh_token"] = new_tokens["refresh_token"]
+
+        save_tokens(email, tokens)
+
+    print(tokens["access_token"])
+
+
+def do_revoke(email):
+    """Revoke and delete stored tokens."""
+    tokens = load_tokens(email)
+    if tokens is None:
+        print("No tokens found.", file=sys.stderr)
+        sys.exit(1)
+
+    # Try to revoke the refresh token first, then access token
+    revoked = False
+    if tokens.get("refresh_token"):
+        revoked = revoke_token(tokens["refresh_token"])
+    if not revoked and tokens.get("access_token"):
+        revoked = revoke_token(tokens["access_token"])
+
+    # Delete local token file
+    path = token_file_for(email)
+    if os.path.exists(path):
+        os.remove(path)
+
+    if revoked:
+        print("Token revoked and deleted.", file=sys.stderr)
+    else:
+        print("Local tokens deleted (remote revocation may have failed).", file=sys.stderr)
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Gmail OAuth2 helper for Matcha")
+    subparsers = parser.add_subparsers(dest="command")
+
+    # auth command
+    auth_parser = subparsers.add_parser("auth", help="Authorize a Gmail account")
+    auth_parser.add_argument("email", help="Gmail address")
+    auth_parser.add_argument("--client-id", help="OAuth2 client ID")
+    auth_parser.add_argument("--client-secret", help="OAuth2 client secret")
+
+    # token command
+    token_parser = subparsers.add_parser("token", help="Get a fresh access token")
+    token_parser.add_argument("email", help="Gmail address")
+
+    # revoke command
+    revoke_parser = subparsers.add_parser("revoke", help="Revoke stored tokens")
+    revoke_parser.add_argument("email", help="Gmail address")
+
+    args = parser.parse_args()
+
+    if args.command == "auth":
+        client_id = args.client_id
+        client_secret = args.client_secret
+
+        # Fall back to stored credentials
+        if not client_id or not client_secret:
+            client_id, client_secret = load_client_credentials()
+
+        if not client_id or not client_secret:
+            print("Error: OAuth2 client credentials required.", file=sys.stderr)
+            print("", file=sys.stderr)
+            print("To set up Gmail OAuth2:", file=sys.stderr)
+            print("  1. Go to https://console.cloud.google.com/apis/credentials", file=sys.stderr)
+            print("  2. Create an OAuth 2.0 Client ID (Desktop application)", file=sys.stderr)
+            print("  3. Enable the Gmail API", file=sys.stderr)
+            print("  4. Run: gmail_oauth.py auth <email> --client-id YOUR_ID --client-secret YOUR_SECRET", file=sys.stderr)
+            print("", file=sys.stderr)
+            print("Or create ~/.config/matcha/oauth_client.json with:", file=sys.stderr)
+            print('  {"client_id": "YOUR_ID", "client_secret": "YOUR_SECRET"}', file=sys.stderr)
+            sys.exit(1)
+
+        do_auth(args.email, client_id, client_secret)
+
+    elif args.command == "token":
+        do_token(args.email)
+
+    elif args.command == "revoke":
+        do_revoke(args.email)
+
+    else:
+        parser.print_help()
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()

docs/docs/setup-guides/gmail.md 🔗

@@ -5,7 +5,124 @@ sidebar_position: 1
 
 # Gmail setup
 
-Matcha requires using app passwords to access your gmail account. App Passwords are available only after 2-Step Verification is turned on.
+Matcha supports two ways to connect to Gmail:
+
+- **OAuth2** (recommended) — sign in with your Google account in the browser, no app password needed
+- **App Password** — generate a 16-character password from Google's security settings
+
+---
+
+## Option A: OAuth2 (recommended)
+
+OAuth2 lets you authorize Matcha through Google's standard sign-in flow. No app passwords required.
+
+### 1. Create a Google Cloud project
+
+1. Go to [console.cloud.google.com](https://console.cloud.google.com).
+2. Click the project dropdown at the top and select **New Project**.
+3. Name it something like "Matcha" and click **Create**.
+
+ ![Create Google Cloud project](../assets/setup-guides/gmail/oauth-create-project.png) 
+
+### 2. Enable the Gmail API
+
+1. In the left sidebar, go to **APIs & Services** → **Library**.
+2. Search for **Gmail API**.
+3. Click it and press **Enable**.
+
+ ![Enable Gmail API](../assets/setup-guides/gmail/oauth-enable-gmail-api.png) 
+
+### 3. Configure the OAuth consent screen
+
+1. Go to **APIs & Services** → **OAuth consent screen**.
+2. Select **External** as the user type and click **Create**.
+3. Fill in the required fields:
+   - **App name**: Matcha
+   - **User support email**: your email
+   - **Developer contact email**: your email
+4. Click **Create**.
+5. On the left sidebar, click **Data access**, search for `Gmail`, check it, and click **Update** → **Save**.
+6. On the left sidebar, click **Audience**, then **Add Users** under **Test Users** enter your Gmail address, and click **Save**.
+
+ ![OAuth consent screen](../assets/setup-guides/gmail/oauth-consent-screen.png) 
+
+> **Note:** Your app will be in "Testing" mode, which is perfectly fine for personal use. Google will show an "unverified app" warning during sign-in — just click **Continue**. Tokens in testing mode expire after 7 days, after which you'll need to re-authorize with `matcha gmail auth`.
+
+### 4. Create OAuth credentials
+
+1. Go to **Clients**.
+2. Click **Create Client** at the top of the screen.
+3. Application type: **Desktop app**.
+4. Name: anything (e.g. "Matcha").
+5. Click **Create**.
+6. Copy the **Client ID** and **Client Secret**.
+
+ ![Create OAuth credentials](../assets/setup-guides/gmail/oauth-create-credentials.png) 
+
+### 5. Save your client credentials
+
+Create the file `~/.config/matcha/oauth_client.json`:
+
+```json
+{
+  "client_id": "YOUR_CLIENT_ID",
+  "client_secret": "YOUR_CLIENT_SECRET"
+}
+```
+
+Or pass them directly in the next step.
+
+### 6. Authorize your Gmail account
+
+Run the following command in your terminal:
+
+```bash
+matcha gmail auth your@gmail.com
+```
+
+Or with inline credentials:
+
+```bash
+matcha gmail auth your@gmail.com --client-id YOUR_ID --client-secret YOUR_SECRET
+```
+
+A browser window will open. Sign in with your Google account and grant access. Once authorized, you'll see "Authorization complete!" in your terminal.
+
+ ![Browser authorization](../assets/setup-guides/gmail/oauth-browser-auth.png) 
+ > **Note**: click "Continue" here
+
+### 7. Add your account in Matcha
+
+From Matcha, open settings and choose to add a new account. Enter:
+
+- **Provider**: gmail
+- **Display name**: The name that will appear on emails you send
+- **Username**: Your Gmail address
+- **Email Address**: The Gmail address to fetch messages from (usually the same as Username)
+- **Auth Method**: oauth2
+
+No password is needed — Matcha will use the tokens from the authorization step.
+
+ ![OAuth account setup in Matcha](../assets/setup-guides/gmail/oauth-add-account.png) 
+
+### Managing OAuth tokens
+
+```bash
+# Get a fresh access token (auto-refreshes if expired)
+matcha gmail token your@gmail.com
+
+# Revoke and delete stored tokens
+matcha gmail revoke your@gmail.com
+
+# Re-authorize (e.g. after token expiry in testing mode)
+matcha gmail auth your@gmail.com
+```
+
+---
+
+## Option B: App Password
+
+If you prefer not to set up OAuth2, you can use an app password instead. App Passwords are available only after 2-Step Verification is turned on.
 
 ## 1. Open Google account security settings
 
@@ -40,11 +157,13 @@ In Matcha account setup:
 - Provider: gmail
 - Display name: The name that will appear on the emails you send
 - Username: Your Gmail address
-- Email Address: The Gmail address used to fetch messages from(most likely the same as the Username)
+- Email Address: The Gmail address used to fetch messages from (most likely the same as the Username)
 - Password: the generated 16-character app password (not your normal Google password)
 
 ![Enter Gmail and app password in Matcha](../assets/setup-guides/gmail/google-add-account.png)
 
+---
+
 ## Troubleshooting
 
 | Issue                              | Solution                                                                                                                          |
@@ -52,3 +171,7 @@ In Matcha account setup:
 | **Invalid credentials**            | Verify you're using the 16-character app password, not your regular Google account password.                                      |
 | **"App passwords" option missing** | Confirm 2-Step Verification is enabled in your account settings. Some organizations restrict app passwords via security policies. |
 | **Connection still fails**         | In Google Account, revoke the current app password and generate a new one. Then update your credentials in Matcha.                |
+| **OAuth2: "unverified app" warning** | This is normal in testing mode. Click **Advanced** → **Go to Matcha (unsafe)** to continue.                                     |
+| **OAuth2: token expired**          | In testing mode tokens expire after 7 days. Run `matcha gmail auth your@gmail.com` to re-authorize.                              |
+| **OAuth2: refresh failed**         | Your refresh token may have been revoked. Run `matcha gmail auth your@gmail.com` to re-authorize from scratch.                    |
+| **"python3 not found"**            | OAuth2 requires Python 3. Install it via your package manager (e.g. `brew install python3`, `apt install python3`).               |

fetcher/fetcher.go 🔗

@@ -168,8 +168,19 @@ func connect(account *config.Account) (*client.Client, error) {
 		}
 	}
 
-	if err := c.Login(account.Email, account.Password); err != nil {
-		return nil, err
+	// Authenticate using OAuth2 (XOAUTH2) or plain password
+	if account.IsOAuth2() {
+		token, err := config.GetOAuth2Token(account.Email)
+		if err != nil {
+			return nil, fmt.Errorf("oauth2: %w", err)
+		}
+		if err := c.Authenticate(newXOAuth2Client(account.Email, token)); err != nil {
+			return nil, fmt.Errorf("XOAUTH2 authentication failed: %w", err)
+		}
+	} else {
+		if err := c.Login(account.Email, account.Password); err != nil {
+			return nil, err
+		}
 	}
 
 	return c, nil

fetcher/xoauth2.go 🔗

@@ -0,0 +1,34 @@
+package fetcher
+
+import (
+	"fmt"
+
+	"github.com/emersion/go-sasl"
+)
+
+// xoauth2Client implements the XOAUTH2 SASL mechanism for Gmail.
+// See https://developers.google.com/gmail/imap/xoauth2-protocol
+type xoauth2Client struct {
+	Username string
+	Token    string
+}
+
+func (a *xoauth2Client) Start() (mech string, ir []byte, err error) {
+	// XOAUTH2 initial response format:
+	// "user=" {User} "\x01auth=Bearer " {Access Token} "\x01\x01"
+	ir = []byte(fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", a.Username, a.Token))
+	return "XOAUTH2", ir, nil
+}
+
+func (a *xoauth2Client) Next(challenge []byte) ([]byte, error) {
+	// Server sent an error challenge; respond with empty to complete the exchange.
+	return []byte{}, nil
+}
+
+// Verify xoauth2Client implements sasl.Client at compile time.
+var _ sasl.Client = (*xoauth2Client)(nil)
+
+// newXOAuth2Client creates a new XOAUTH2 SASL client.
+func newXOAuth2Client(username, token string) sasl.Client {
+	return &xoauth2Client{Username: username, Token: token}
+}

main.go 🔗

@@ -176,6 +176,15 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		return m, m.current.Init()
 
+	case tui.OAuth2CompleteMsg:
+		if msg.Err != nil {
+			log.Printf("OAuth2 authorization failed: %v", msg.Err)
+		}
+		// After OAuth2 flow, go to the choice menu so user can proceed
+		m.current = tui.NewChoice()
+		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		return m, m.current.Init()
+
 	case tui.Credentials:
 		// Add new account or update existing
 		account := config.Account{
@@ -185,6 +194,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			Password:        msg.Password,
 			ServiceProvider: msg.Provider,
 			FetchEmail:      msg.FetchEmail,
+			AuthMethod:      msg.AuthMethod,
 		}
 
 		if msg.Provider == "custom" {
@@ -231,6 +241,15 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return m, tea.Quit
 		}
 
+		// If OAuth2, launch the authorization flow after saving the account
+		if account.IsOAuth2() {
+			email := account.Email
+			return m, func() tea.Msg {
+				err := config.RunOAuth2Flow(email, "", "")
+				return tui.OAuth2CompleteMsg{Email: email, Err: err}
+			}
+		}
+
 		if isEdit {
 			m.current = tui.NewSettings(m.config)
 		} else {
@@ -1984,6 +2003,50 @@ func checkForUpdatesCmd() tea.Cmd {
 // runUpdateCLI implements the CLI entrypoint for `matcha update`.
 // It detects the likely installation method and attempts the appropriate
 // update path (Homebrew, Snap, or GitHub release binary extract).
+// runGmailOAuthCLI handles the "matcha gmail" subcommand for OAuth2 management.
+// Usage:
+//
+//	matcha gmail auth   <email> [--client-id ID --client-secret SECRET]
+//	matcha gmail token  <email>
+//	matcha gmail revoke <email>
+func runGmailOAuthCLI(args []string) {
+	if len(args) < 1 {
+		fmt.Fprintln(os.Stderr, "Usage: matcha gmail <auth|token|revoke> <email> [flags]")
+		fmt.Fprintln(os.Stderr, "")
+		fmt.Fprintln(os.Stderr, "Commands:")
+		fmt.Fprintln(os.Stderr, "  auth   <email>  Authorize a Gmail account via OAuth2 (opens browser)")
+		fmt.Fprintln(os.Stderr, "  token  <email>  Print a fresh access token (refreshes automatically)")
+		fmt.Fprintln(os.Stderr, "  revoke <email>  Revoke and delete stored OAuth2 tokens")
+		fmt.Fprintln(os.Stderr, "")
+		fmt.Fprintln(os.Stderr, "Before using OAuth2, create ~/.config/matcha/oauth_client.json with:")
+		fmt.Fprintln(os.Stderr, `  {"client_id": "YOUR_ID", "client_secret": "YOUR_SECRET"}`)
+		fmt.Fprintln(os.Stderr, "")
+		fmt.Fprintln(os.Stderr, "Get credentials at: https://console.cloud.google.com/apis/credentials")
+		os.Exit(1)
+	}
+
+	// Find the Python script and pass through to it
+	script, err := config.OAuthScriptPath()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+		os.Exit(1)
+	}
+
+	cmdArgs := append([]string{script}, args...)
+	cmd := exec.Command("python3", cmdArgs...)
+	cmd.Stdin = os.Stdin
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+
+	if err := cmd.Run(); err != nil {
+		if exitErr, ok := err.(*exec.ExitError); ok {
+			os.Exit(exitErr.ExitCode())
+		}
+		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+		os.Exit(1)
+	}
+}
+
 func runUpdateCLI() error {
 	const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
 	resp, err := http.Get(api)
@@ -2302,6 +2365,12 @@ func main() {
 		os.Exit(0)
 	}
 
+	// Gmail OAuth2 CLI subcommand: matcha gmail <auth|token|revoke> <email> [flags]
+	if len(os.Args) > 1 && os.Args[1] == "gmail" {
+		runGmailOAuthCLI(os.Args[2:])
+		os.Exit(0)
+	}
+
 	cfg, err := config.LoadConfig()
 	if err == nil && cfg.Theme != "" {
 		theme.SetTheme(cfg.Theme)

sender/sender.go 🔗

@@ -24,6 +24,25 @@ import (
 	"go.mozilla.org/pkcs7"
 )
 
+// xoauth2Auth implements the SMTP XOAUTH2 authentication mechanism for OAuth2.
+// See https://developers.google.com/gmail/imap/xoauth2-protocol
+type xoauth2Auth struct {
+	username, token string
+}
+
+func (a *xoauth2Auth) Start(server *smtp.ServerInfo) (string, []byte, error) {
+	resp := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", a.username, a.token)
+	return "XOAUTH2", []byte(resp), nil
+}
+
+func (a *xoauth2Auth) Next(fromServer []byte, more bool) ([]byte, error) {
+	if more {
+		// Server sent an error challenge; respond with empty to finish.
+		return []byte{}, nil
+	}
+	return nil, nil
+}
+
 // loginAuth implements the SMTP LOGIN authentication mechanism.
 // Some SMTP servers (e.g. Mailo) only support LOGIN and not PLAIN.
 type loginAuth struct {
@@ -397,7 +416,15 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 	// c.Extension("AUTH") returns the list of supported mechanisms.
 	if ok, mechs := c.Extension("AUTH"); ok {
 		mechList := strings.ToUpper(mechs)
-		if strings.Contains(mechList, "PLAIN") {
+
+		if account.IsOAuth2() {
+			// Use XOAUTH2 for OAuth2-enabled accounts
+			token, tokenErr := config.GetOAuth2Token(account.Email)
+			if tokenErr != nil {
+				return fmt.Errorf("oauth2: %w", tokenErr)
+			}
+			err = c.Auth(&xoauth2Auth{username: account.Email, token: token})
+		} else if strings.Contains(mechList, "PLAIN") {
 			err = c.Auth(plainAuth)
 		} else if strings.Contains(mechList, "LOGIN") {
 			err = c.Auth(loginAuthFallback)

tui/login.go 🔗

@@ -13,6 +13,7 @@ type Login struct {
 	focusIndex int
 	inputs     []textinput.Model
 	showCustom bool // Show custom server fields
+	useOAuth2  bool // Use OAuth2 instead of password (for gmail)
 	isEditMode bool // Whether we're editing an existing account
 	accountID  string
 	hideTips   bool
@@ -25,6 +26,7 @@ const (
 	inputName
 	inputEmail
 	inputFetchEmail
+	inputAuthMethod // "password" or "oauth2" (shown for gmail)
 	inputPassword
 	inputIMAPServer
 	inputIMAPPort
@@ -61,6 +63,9 @@ func NewLogin(hideTips bool) *Login {
 		case inputFetchEmail:
 			t.Placeholder = "Email Address"
 			t.Prompt = "📧 > "
+		case inputAuthMethod:
+			t.Placeholder = "Auth Method (password or oauth2)"
+			t.Prompt = "🔐 > "
 		case inputPassword:
 			t.Placeholder = "Password / App Password"
 			t.EchoMode = textinput.EchoPassword
@@ -107,13 +112,14 @@ func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case "enter":
 			// Check if provider is "custom" to show/hide custom fields
 			provider := m.inputs[inputProvider].Value()
-			if provider == "custom" {
-				m.showCustom = true
-			} else {
-				m.showCustom = false
-			}
+			m.showCustom = provider == "custom"
+			m.useOAuth2 = m.inputs[inputAuthMethod].Value() == "oauth2"
 
 			lastFieldIndex := inputPassword
+			if m.useOAuth2 {
+				// OAuth2: last field before submit is the auth method field
+				lastFieldIndex = inputAuthMethod
+			}
 			if m.showCustom {
 				lastFieldIndex = inputSMTPPort
 			}
@@ -133,6 +139,11 @@ func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					}
 				}
 
+				authMethod := "password"
+				if m.useOAuth2 {
+					authMethod = "oauth2"
+				}
+
 				return m, func() tea.Msg {
 					return Credentials{
 						Provider:   m.inputs[inputProvider].Value(),
@@ -144,6 +155,7 @@ func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 						IMAPPort:   imapPort,
 						SMTPServer: m.inputs[inputSMTPServer].Value(),
 						SMTPPort:   smtpPort,
+						AuthMethod: authMethod,
 					}
 				}
 			}
@@ -152,11 +164,15 @@ func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case "tab", "shift+tab", "up", "down":
 			s := msg.String()
 
-			// Check provider to update showCustom
+			// Check provider to update showCustom and useOAuth2
 			provider := m.inputs[inputProvider].Value()
 			m.showCustom = provider == "custom"
+			m.useOAuth2 = m.inputs[inputAuthMethod].Value() == "oauth2"
 
 			maxIndex := inputPassword
+			if m.useOAuth2 {
+				maxIndex = inputAuthMethod
+			}
 			if m.showCustom {
 				maxIndex = inputSMTPPort
 			}
@@ -173,10 +189,32 @@ func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.focusIndex = maxIndex
 			}
 
+			// Skip password field when using OAuth2
+			if m.useOAuth2 && m.focusIndex == inputPassword {
+				if s == "up" || s == "shift+tab" {
+					m.focusIndex = inputAuthMethod
+				} else {
+					m.focusIndex = 0
+				}
+			}
+
+			// Skip auth method field when not Gmail (only Gmail supports OAuth2)
+			if provider != "gmail" && m.focusIndex == inputAuthMethod {
+				if s == "up" || s == "shift+tab" {
+					m.focusIndex = inputFetchEmail
+				} else {
+					m.focusIndex = inputPassword
+				}
+			}
+
 			// Skip custom fields if not showing them
 			if !m.showCustom && m.focusIndex > inputPassword {
 				if s == "up" || s == "shift+tab" {
-					m.focusIndex = inputPassword
+					if m.useOAuth2 {
+						m.focusIndex = inputAuthMethod
+					} else {
+						m.focusIndex = inputPassword
+					}
 				} else {
 					m.focusIndex = 0
 				}
@@ -203,6 +241,7 @@ func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	// Check if provider changed
 	provider := m.inputs[inputProvider].Value()
 	m.showCustom = provider == "custom"
+	m.useOAuth2 = m.inputs[inputAuthMethod].Value() == "oauth2"
 
 	return m, tea.Batch(cmds...)
 }
@@ -229,6 +268,8 @@ func (m *Login) View() tea.View {
 		tip = "Your full email address used to log in."
 	case inputFetchEmail:
 		tip = "The email address to fetch messages from (often same as Username)."
+	case inputAuthMethod:
+		tip = "Type 'oauth2' for Gmail OAuth2 or 'password' for app password."
 	case inputPassword:
 		tip = "Your password or an app-specific password if using 2FA."
 	case inputIMAPServer:
@@ -241,6 +282,8 @@ func (m *Login) View() tea.View {
 		tip = "The port for the SMTP server (usually 587 for TLS)."
 	}
 
+	isGmail := m.inputs[inputProvider].Value() == "gmail"
+
 	views := []string{
 		titleStyle.Render(title),
 		"Enter your email account credentials.",
@@ -249,7 +292,18 @@ func (m *Login) View() tea.View {
 		m.inputs[inputName].View(),
 		m.inputs[inputEmail].View(),
 		m.inputs[inputFetchEmail].View(),
-		m.inputs[inputPassword].View(),
+	}
+
+	// Show auth method selector for Gmail
+	if isGmail {
+		views = append(views, m.inputs[inputAuthMethod].View())
+	}
+
+	// Hide password field when using OAuth2
+	if !m.useOAuth2 {
+		views = append(views, m.inputs[inputPassword].View())
+	} else {
+		views = append(views, accountEmailStyle.Render("OAuth2 selected — browser authorization will open after submit"))
 	}
 
 	if m.showCustom {

tui/messages.go 🔗

@@ -47,6 +47,18 @@ type Credentials struct {
 	IMAPPort   int
 	SMTPServer string
 	SMTPPort   int
+	AuthMethod string // "password" or "oauth2"
+}
+
+// StartOAuth2Msg is sent when the user requests OAuth2 authorization for a Gmail account.
+type StartOAuth2Msg struct {
+	Email string
+}
+
+// OAuth2CompleteMsg is sent when OAuth2 authorization completes.
+type OAuth2CompleteMsg struct {
+	Email string
+	Err   error
 }
 
 type ChooseServiceMsg struct {