Detailed changes
@@ -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 != "" {
@@ -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()
+}
@@ -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()
@@ -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**.
+
+ 
+
+### 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**.
+
+ 
+
+### 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**.
+
+ 
+
+> **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**.
+
+ 
+
+### 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.
+
+ 
+ > **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.
+
+ 
+
+### 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)

+---
+
## 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`). |
@@ -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
@@ -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}
+}
@@ -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)
@@ -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)
@@ -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 {
@@ -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 {