diff --git a/config/README.md b/config/README.md index 2cd94b77dfb300865cc34036c906a35ac8345a04..9dd709e8efc0b4625d08c10bc899360a1c7a8915 100644 --- a/config/README.md +++ b/config/README.md @@ -6,7 +6,7 @@ The `config` package handles all persistent application state: user configuratio This package acts as the data layer for Matcha. It manages: -- **Account configuration** with multi-account support (Gmail, iCloud, custom IMAP/SMTP) +- **Account configuration** with multi-account support (Gmail, Outlook, iCloud, custom IMAP/SMTP) - **Secure credential storage** via the OS keyring (with automatic migration from plain-text passwords), or inside the encrypted config when encryption is enabled - **Local caches** for emails, email bodies, contacts, drafts, and folder listings to enable fast startup and offline browsing - **Email body caching** for instant display of previously viewed emails without network round-trips @@ -51,7 +51,7 @@ On startup, `MigrateCacheFiles()` moves any cache files from the old location (` | `encryption.go` | Optional at-rest encryption using AES-256-GCM with Argon2id key derivation. Provides `SecureReadFile`/`SecureWriteFile` (transparent encryption wrappers used by all other files), `EnableSecureMode`/`DisableSecureMode`, password verification via an encrypted sentinel phrase, and session key management. | | `signature.go` | Loads and saves the user's email signature from `~/.config/matcha/signature.txt`. | | `oauth.go` | OAuth2 integration — token retrieval, authorization flow launcher, and embedded Python helper extraction. | -| `oauth_script.py` | Embedded Gmail OAuth2 helper script (browser-based auth, token refresh, secure storage). | +| `oauth_script.py` | Embedded OAuth2 helper script supporting Gmail and Outlook (browser-based auth, token refresh, secure storage). | | `config_test.go` | Unit tests for configuration logic. | ## Encryption @@ -92,14 +92,14 @@ Attachment binary data is not cached — only metadata (filename, MIME type, par ## OAuth2 / XOAUTH2 -Accounts with `auth_method: "oauth2"` use Gmail's XOAUTH2 mechanism instead of passwords. The flow works across three layers: +Accounts with `auth_method: "oauth2"` use the XOAUTH2 mechanism instead of passwords. This is supported for Gmail and Outlook. The flow works across three layers: 1. **`config/oauth.go`** — Go-side orchestration. Extracts the embedded Python helper to `~/.config/matcha/oauth/`, invokes it to run the browser-based authorization flow (`RunOAuth2Flow`) or to retrieve a fresh access token (`GetOAuth2Token`). The `IsOAuth2()` method on `Account` checks the auth method. -2. **`config/oauth_script.py`** — Embedded Python script that handles the full OAuth2 lifecycle: - - `auth` — Opens a browser for Google authorization, captures the callback on `localhost:8189`, exchanges the code for tokens, and saves them to `~/.config/matcha/oauth_tokens/`. +2. **`config/oauth_script.py`** — Embedded Python script that handles the full OAuth2 lifecycle for both Gmail and Outlook: + - `auth` — Opens a browser for authorization (Google or Microsoft), captures the callback on `localhost:8189`, exchanges the code for tokens, and saves them to `~/.config/matcha/oauth_tokens/`. The provider is auto-detected from the email domain or can be specified with `--provider`. - `token` — Returns a fresh access token, automatically refreshing if expired (with a 5-minute buffer). - - `revoke` — Revokes tokens with Google and deletes local storage. - - Client credentials are stored in `~/.config/matcha/oauth_client.json`. + - `revoke` — Revokes tokens and deletes local storage. + - Client credentials are stored per provider: `~/.config/matcha/oauth_client.json` (Gmail), `~/.config/matcha/oauth_client_outlook.json` (Outlook). -3. **`fetcher/xoauth2.go`** — Implements the XOAUTH2 SASL mechanism (`sasl.Client` interface) for IMAP/SMTP authentication. Formats the initial response as `user=\x01auth=Bearer \x01\x01` per Google's XOAUTH2 protocol spec. +3. **`fetcher/xoauth2.go`** — Implements the XOAUTH2 SASL mechanism (`sasl.Client` interface) for IMAP/SMTP authentication. Formats the initial response as `user=\x01auth=Bearer \x01\x01` per the XOAUTH2 protocol spec. diff --git a/config/config.go b/config/config.go index c719db4ae74600b44751328348fe073c6198067c..f1569b617ca83ca3677a95b4a1b5a8207ee3aa62 100644 --- a/config/config.go +++ b/config/config.go @@ -18,7 +18,7 @@ type Account struct { Name string `json:"name"` Email string `json:"email"` Password string `json:"-"` // "-" prevents the password from being saved to config.json - ServiceProvider string `json:"service_provider"` // "gmail", "icloud", or "custom" + ServiceProvider string `json:"service_provider"` // "gmail", "outlook", "icloud", or "custom" // FetchEmail is the single email address for which messages should be fetched. // If empty, it will default to `Email` when accounts are added. FetchEmail string `json:"fetch_email,omitempty"` @@ -76,6 +76,8 @@ func (a *Account) GetIMAPServer() string { switch a.ServiceProvider { case "gmail": return "imap.gmail.com" + case "outlook": + return "outlook.office365.com" case "icloud": return "imap.mail.me.com" case "custom": @@ -88,7 +90,7 @@ func (a *Account) GetIMAPServer() string { // GetIMAPPort returns the IMAP port for the account. func (a *Account) GetIMAPPort() int { switch a.ServiceProvider { - case "gmail", "icloud": + case "gmail", "outlook", "icloud": return 993 case "custom": if a.IMAPPort != 0 { @@ -105,6 +107,8 @@ func (a *Account) GetSMTPServer() string { switch a.ServiceProvider { case "gmail": return "smtp.gmail.com" + case "outlook": + return "smtp.office365.com" case "icloud": return "smtp.mail.me.com" case "custom": @@ -117,7 +121,7 @@ func (a *Account) GetSMTPServer() string { // GetSMTPPort returns the SMTP port for the account. func (a *Account) GetSMTPPort() int { switch a.ServiceProvider { - case "gmail", "icloud": + case "gmail", "outlook", "icloud": return 587 case "custom": if a.SMTPPort != 0 { diff --git a/config/oauth.go b/config/oauth.go index 997347f43182a6ddb5b30b5afeddaec2d9e70ef6..f11c3b7ec8848148ee68b074f9cf6095eaf5e272 100644 --- a/config/oauth.go +++ b/config/oauth.go @@ -17,7 +17,7 @@ func (a *Account) IsOAuth2() bool { return a.AuthMethod == "oauth2" } -// OAuthScriptPath returns the path to the Gmail OAuth2 Python helper script. +// OAuthScriptPath returns the path to the OAuth2 Python helper script. // The script is embedded in the binary and extracted to ~/.config/matcha/oauth/ // on first use. func OAuthScriptPath() (string, error) { @@ -27,7 +27,7 @@ func OAuthScriptPath() (string, error) { } scriptDir := filepath.Join(dir, "oauth") - scriptPath := filepath.Join(scriptDir, "gmail_oauth.py") + scriptPath := filepath.Join(scriptDir, "oauth.py") // Always overwrite with the embedded version to stay in sync with the binary if err := os.MkdirAll(scriptDir, 0700); err != nil { @@ -66,14 +66,18 @@ func GetOAuth2Token(email string) (string, error) { // RunOAuth2Flow launches the OAuth2 authorization flow by invoking the Python // helper script. It opens the user's browser for authorization. +// provider should be "gmail" or "outlook". If empty, the script auto-detects from the email. // clientID and clientSecret are optional — if empty, the script uses stored credentials. -func RunOAuth2Flow(email, clientID, clientSecret string) error { +func RunOAuth2Flow(email, provider, clientID, clientSecret string) error { script, err := OAuthScriptPath() if err != nil { return err } args := []string{script, "auth", email} + if provider != "" { + args = append(args, "--provider", provider) + } if clientID != "" && clientSecret != "" { args = append(args, "--client-id", clientID, "--client-secret", clientSecret) } diff --git a/config/oauth_script.py b/config/oauth_script.py index 83935570ef8a8fc74e634ff32cd5c8f7ab2af7f8..325451d7c98f90ce2e4ddff38872deb5d64ad9c9 100755 --- a/config/oauth_script.py +++ b/config/oauth_script.py @@ -1,17 +1,17 @@ #!/usr/bin/env python3 """ -Gmail OAuth2 helper for Matcha email client. +OAuth2 helper for Matcha email client. -Handles the full OAuth2 flow for Gmail: +Handles the full OAuth2 flow for Gmail and Outlook: - 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 [--client-id ID --client-secret SECRET] - gmail_oauth.py token - gmail_oauth.py revoke + oauth.py auth [--provider gmail|outlook] [--client-id ID --client-secret SECRET] + oauth.py token + oauth.py revoke The 'auth' command initiates the OAuth2 flow, opening a browser. The 'token' command prints a fresh access token to stdout (refreshing if needed). @@ -31,14 +31,56 @@ 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" +# --- Provider configuration --- + +PROVIDERS = { + "gmail": { + "name": "Gmail", + "auth_endpoint": "https://accounts.google.com/o/oauth2/v2/auth", + "token_endpoint": "https://oauth2.googleapis.com/token", + "revoke_endpoint": "https://oauth2.googleapis.com/revoke", + "scopes": ["https://mail.google.com/"], + "extra_auth_params": { + "access_type": "offline", + "prompt": "consent", + }, + "credentials_help": [ + "To set up Gmail OAuth2:", + " 1. Go to https://console.cloud.google.com/apis/credentials", + " 2. Create an OAuth 2.0 Client ID (Desktop application)", + " 3. Enable the Gmail API", + ], + }, + "outlook": { + "name": "Outlook", + "auth_endpoint": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + "token_endpoint": "https://login.microsoftonline.com/common/oauth2/v2.0/token", + "revoke_endpoint": None, # Microsoft does not support token revocation via endpoint + "scopes": [ + "https://outlook.office365.com/IMAP.AccessAsUser.All", + "https://outlook.office365.com/SMTP.Send", + "offline_access", + ], + "extra_auth_params": { + "prompt": "consent", + }, + "credentials_help": [ + "To set up Outlook OAuth2:", + " 1. Go to https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade", + " 2. Register a new application (any name, e.g. 'Matcha')", + " 3. Set a redirect URI: http://localhost:8189 (Web platform)", + " 4. Under 'Certificates & secrets', create a new client secret", + " 5. Under 'API permissions', add:", + " - Microsoft Graph > Delegated > email", + " - Microsoft Graph > Delegated > offline_access", + " - Microsoft Graph > Delegated > User.Read", + " - Microsoft Graph > Delegated > Mail.ReadWrite", + " - Microsoft Graph > Delegated > Mail.Send", + " - Microsoft Graph > Delegated > IMAP.AccessAsUser.All", + " - Microsoft Graph > Delegated > SMTP.Send", + ], + }, +} REDIRECT_PORT = 8189 REDIRECT_URI = f"http://localhost:{REDIRECT_PORT}" @@ -75,63 +117,94 @@ def save_tokens(email, tokens): json.dump(tokens, f, indent=2) -def load_client_credentials(): - """Load OAuth2 client credentials from ~/.config/matcha/oauth_client.json.""" +def client_credentials_file_for(provider): + """Return the client credentials file path for a given provider.""" 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: + if provider == "gmail": + # Keep backwards-compatible path for Gmail + return os.path.join(home, ".config", "matcha", "oauth_client.json") + return os.path.join(home, ".config", "matcha", f"oauth_client_{provider}.json") + + +def load_client_credentials(provider): + """Load OAuth2 client credentials for the given provider.""" + path = client_credentials_file_for(provider) + if not os.path.exists(path): + # Also try the generic path as fallback + home = os.path.expanduser("~") + generic = os.path.join(home, ".config", "matcha", "oauth_client.json") + if provider != "gmail" and os.path.exists(generic): + with open(generic, "r") as f: + data = json.load(f) + # Only use generic if it has provider-specific keys + pid = data.get("provider") + if pid == provider: + return data.get("client_id"), data.get("client_secret") + if not os.path.exists(path): + return None, None + with open(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) +def save_client_credentials(provider, client_id, client_secret): + """Save OAuth2 client credentials for the given provider.""" + path = client_credentials_file_for(provider) + fd = os.open(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): +def exchange_code(code, client_id, client_secret, provider): """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") + cfg = PROVIDERS[provider] + 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(cfg["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): +def refresh_access_token(refresh_token, client_id, client_secret, provider): """Use a refresh token to get a new access token.""" - data = urllib.parse.urlencode({ + cfg = PROVIDERS[provider] + params = { "refresh_token": refresh_token, "client_id": client_id, "client_secret": client_secret, "grant_type": "refresh_token", - }).encode() + } + # Outlook requires scope on refresh + if provider == "outlook": + params["scope"] = " ".join(cfg["scopes"]) - req = urllib.request.Request(TOKEN_ENDPOINT, data=data, method="POST") + data = urllib.parse.urlencode(params).encode() + + req = urllib.request.Request(cfg["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): +def revoke_token(token, provider): """Revoke an OAuth2 token.""" + cfg = PROVIDERS[provider] + if cfg["revoke_endpoint"] is None: + return False + data = urllib.parse.urlencode({"token": token}).encode() - req = urllib.request.Request(REVOKE_ENDPOINT, data=data, method="POST") + req = urllib.request.Request(cfg["revoke_endpoint"], data=data, method="POST") req.add_header("Content-Type", "application/x-www-form-urlencoded") try: @@ -167,12 +240,14 @@ class OAuthCallbackHandler(http.server.BaseHTTPRequestHandler): self.send_response(400) self.send_header("Content-Type", "text/html") self.end_headers() - self.wfile.write(f""" + self.wfile.write( + f"""

Authorization failed

-

Error: {params['error'][0]}

+

Error: {params["error"][0]}

- """.encode()) + """.encode() + ) else: self.send_response(404) self.end_headers() @@ -182,23 +257,51 @@ class OAuthCallbackHandler(http.server.BaseHTTPRequestHandler): pass -def do_auth(email, client_id, client_secret): +def detect_provider(email): + """Detect the OAuth2 provider from an email address.""" + domain = email.rsplit("@", 1)[-1].lower() if "@" in email else "" + outlook_domains = { + "outlook.com", + "hotmail.com", + "live.com", + "msn.com", + "outlook.co.uk", + "hotmail.co.uk", + "live.co.uk", + "outlook.de", + "hotmail.de", + "outlook.fr", + "hotmail.fr", + "outlook.it", + "hotmail.it", + "outlook.es", + "hotmail.es", + "outlook.jp", + "hotmail.co.jp", + } + if domain in ("gmail.com", "googlemail.com"): + return "gmail" + if domain in outlook_domains: + return "outlook" + return None + + +def do_auth(email, provider, client_id, client_secret): """Run the full OAuth2 authorization flow.""" - # Generate PKCE code verifier/challenge for extra security + cfg = PROVIDERS[provider] state = secrets.token_urlsafe(32) - auth_params = urllib.parse.urlencode({ + auth_params = { "client_id": client_id, "redirect_uri": REDIRECT_URI, "response_type": "code", - "scope": " ".join(SCOPES), - "access_type": "offline", - "prompt": "consent", + "scope": " ".join(cfg["scopes"]), "state": state, "login_hint": email, - }) + } + auth_params.update(cfg["extra_auth_params"]) - auth_url = f"{AUTH_ENDPOINT}?{auth_params}" + auth_url = f"{cfg['auth_endpoint']}?{urllib.parse.urlencode(auth_params)}" # Reset handler state OAuthCallbackHandler.auth_code = None @@ -208,7 +311,7 @@ def do_auth(email, client_id, client_secret): 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"Opening browser for {cfg['name']} 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) @@ -228,7 +331,7 @@ def do_auth(email, client_id, client_secret): print("Authorization code received, exchanging for tokens...", file=sys.stderr) # Exchange code for tokens - token_response = exchange_code(code, client_id, client_secret) + token_response = exchange_code(code, client_id, client_secret, provider) if "error" in token_response: print(f"Token exchange error: {token_response['error']}", file=sys.stderr) @@ -241,10 +344,11 @@ def do_auth(email, client_id, client_secret): "expires_at": int(time.time()) + token_response.get("expires_in", 3600), "token_type": token_response.get("token_type", "Bearer"), "email": email, + "provider": provider, } save_tokens(email, tokens) - save_client_credentials(client_id, client_secret) + save_client_credentials(provider, client_id, client_secret) print("Authorization complete! Tokens saved.", file=sys.stderr) # Print the access token to stdout for immediate use @@ -258,9 +362,11 @@ def do_token(email): print("No tokens found. Run 'auth' first.", file=sys.stderr) sys.exit(1) + provider = tokens.get("provider", "gmail") + # 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() + client_id, client_secret = load_client_credentials(provider) if not client_id or not client_secret: print("No client credentials found. Run 'auth' first.", file=sys.stderr) sys.exit(1) @@ -271,7 +377,9 @@ def do_token(email): sys.exit(1) try: - new_tokens = refresh_access_token(refresh_token, client_id, client_secret) + new_tokens = refresh_access_token( + refresh_token, client_id, client_secret, provider + ) except urllib.error.HTTPError as e: print(f"Token refresh failed: {e}", file=sys.stderr) sys.exit(1) @@ -294,12 +402,14 @@ def do_revoke(email): print("No tokens found.", file=sys.stderr) sys.exit(1) + provider = tokens.get("provider", "gmail") + # Try to revoke the refresh token first, then access token revoked = False if tokens.get("refresh_token"): - revoked = revoke_token(tokens["refresh_token"]) + revoked = revoke_token(tokens["refresh_token"], provider) if not revoked and tokens.get("access_token"): - revoked = revoke_token(tokens["access_token"]) + revoked = revoke_token(tokens["access_token"], provider) # Delete local token file path = token_file_for(email) @@ -309,51 +419,73 @@ def do_revoke(email): if revoked: print("Token revoked and deleted.", file=sys.stderr) else: - print("Local tokens deleted (remote revocation may have failed).", file=sys.stderr) + print( + "Local tokens deleted (remote revocation may have failed).", file=sys.stderr + ) def main(): - parser = argparse.ArgumentParser(description="Gmail OAuth2 helper for Matcha") + parser = argparse.ArgumentParser(description="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 = subparsers.add_parser("auth", help="Authorize an email account") + auth_parser.add_argument("email", help="Email address") + auth_parser.add_argument( + "--provider", + help="OAuth2 provider (gmail or outlook)", + choices=["gmail", "outlook"], + ) 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") + token_parser.add_argument("email", help="Email address") # revoke command revoke_parser = subparsers.add_parser("revoke", help="Revoke stored tokens") - revoke_parser.add_argument("email", help="Gmail address") + revoke_parser.add_argument("email", help="Email address") args = parser.parse_args() if args.command == "auth": + provider = args.provider + if not provider: + provider = detect_provider(args.email) + if not provider: + print( + "Error: Could not detect provider from email address.", file=sys.stderr + ) + print("Use --provider gmail or --provider outlook", file=sys.stderr) + sys.exit(1) + 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() + client_id, client_secret = load_client_credentials(provider) if not client_id or not client_secret: - print("Error: OAuth2 client credentials required.", file=sys.stderr) + cfg = PROVIDERS[provider] + print( + f"Error: OAuth2 client credentials required for {cfg['name']}.", + 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 --client-id YOUR_ID --client-secret YOUR_SECRET", file=sys.stderr) + for line in cfg["credentials_help"]: + print(line, 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) + cred_file = client_credentials_file_for(provider) + print(f"Create {cred_file} 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) + do_auth(args.email, provider, client_id, client_secret) elif args.command == "token": do_token(args.email) diff --git a/docs/docs/Features/CLI.md b/docs/docs/Features/CLI.md index 161878509a4e8525fa8ebb410c063be5a47f091d..96aa46ac3e8a57958fcc0d49b326e30a50f14f44 100644 --- a/docs/docs/Features/CLI.md +++ b/docs/docs/Features/CLI.md @@ -166,17 +166,22 @@ matcha update Automatically detects your installation method (Homebrew, Snap, Flatpak, WinGet, or binary) and updates accordingly. -## matcha gmail +## matcha oauth -Manage Gmail OAuth2 authorization. +Manage OAuth2 authorization for Gmail and Outlook. ```bash -matcha gmail auth # Authorize a Gmail account (opens browser) -matcha gmail token # Print a fresh access token -matcha gmail revoke # Revoke and delete stored tokens +matcha oauth auth # Authorize an account (opens browser, auto-detects provider) +matcha oauth auth --provider outlook # Specify provider explicitly +matcha oauth token # Print a fresh access token +matcha oauth revoke # Revoke and delete stored tokens ``` -Requires OAuth2 client credentials in `~/.config/matcha/oauth_client.json`. See the [Gmail setup guide](../setup-guides/gmail.md) for details. +`matcha gmail` is kept as an alias for backwards compatibility. + +Client credentials are stored per provider: +- Gmail: `~/.config/matcha/oauth_client.json` — see the [Gmail setup guide](../setup-guides/gmail.md) +- Outlook: `~/.config/matcha/oauth_client_outlook.json` — see the [Outlook setup guide](../setup-guides/outlook.md) ## matcha version diff --git a/docs/docs/setup-guides/gmail.md b/docs/docs/setup-guides/gmail.md index f81c4fcfb2f35ca2761fd3f37cbe43d1ee6a3236..f85513a5b221c71412b3efc426b0c4ad20687ae1 100644 --- a/docs/docs/setup-guides/gmail.md +++ b/docs/docs/setup-guides/gmail.md @@ -77,13 +77,13 @@ Or pass them directly in the next step. Run the following command in your terminal: ```bash -matcha gmail auth your@gmail.com +matcha oauth auth your@gmail.com ``` Or with inline credentials: ```bash -matcha gmail auth your@gmail.com --client-id YOUR_ID --client-secret YOUR_SECRET +matcha oauth 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. @@ -110,13 +110,13 @@ No password is needed — Matcha will use the tokens from the authorization step ```bash # Get a fresh access token (auto-refreshes if expired) -matcha gmail token your@gmail.com +matcha oauth token your@gmail.com # Revoke and delete stored tokens -matcha gmail revoke your@gmail.com +matcha oauth revoke your@gmail.com # Re-authorize (e.g. after token expiry in testing mode) -matcha gmail auth your@gmail.com +matcha oauth auth your@gmail.com ``` --- @@ -173,6 +173,6 @@ In Matcha account setup: | **"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. | +| **OAuth2: token expired** | In testing mode tokens expire after 7 days. Run `matcha oauth auth your@gmail.com` to re-authorize. | +| **OAuth2: refresh failed** | Your refresh token may have been revoked. Run `matcha oauth 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`). | diff --git a/docs/docs/setup-guides/outlook.md b/docs/docs/setup-guides/outlook.md new file mode 100644 index 0000000000000000000000000000000000000000..c107e56a8abc11c167584bdfc7b87a1a917cb8ba --- /dev/null +++ b/docs/docs/setup-guides/outlook.md @@ -0,0 +1,176 @@ +--- +title: Outlook +sidebar_position: 3 +--- + +# Microsoft Email + +If you have a personal Outlook or legacy Hotmail account, you won't be able to authenticate with Matcha normally using OAuth without some special configuration. In this guide, we will create a [Microsoft Entra][1] App, give that app the required scopes and permissions, and then authenticate to that app with Matcha's OAuth tool. + +[1]: https://www.microsoft.com/en-us/security/business/microsoft-entra + +# Microsoft Entra + +In this section, we will create a Microsoft Entra App with the required scopes and permissions. + +### Azure Account + +First, create a [free](https://azure.microsoft.com/free/?WT.mc_id=A261C142F) Azure account affiliated with your personal Outlook account. You will have to enter credit card details, but your account will not be charged. + +### Tenant + +A tenant is a collection of user identities, apps, and groups that are managed by Microsoft Entra ID. It's a secure boundary that controls access to an organization's resources. Once your Azure account is created, a default tenant called **Default Directory** will be created in your Entra Portal. + +You can check your Tenants by going to the top-right of the [Microsoft Entra Admin Center](https://entra.microsoft.com/#home) and seeing the name of the tenant right below your username. + +### Register an Application + +Now, we will [register an app][3] with the Microsoft Identity platform. + +[3]: https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app?tabs=certificate + +1. Sign in to the **Microsoft Entra admin center** as at least a *Cloud Application Administrator*. +2. If you have access to multiple tenants, use the Settings icon in the top menu to switch to the tenant in which you want to register the application from the Directories + subscriptions menu. +3. Browse to **Identity > Applications > App registrations** and select **New registration**. +4. Enter a display Name for your application -- call it something like `Matcha{YourUsername}`. +5. Under **Who can use this application or access this API?**, select **Accounts in any organizational directory (Any Microsoft Entra ID tenant - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)** -- this will allow your application to use the `common` tenant when authenticating later. + +### Giving the App Scopes + +We must give our App the required scope permissions to access user's email through IMAP and SMTP. + +In the left menu, go to **App Registrations > All Applications > Your App**. Then, under the **Api Permissions** menu... + +1. Select **Add a Permission > Microsoft Graph API** and select the following scopes under **Delegated permissions**: + + - `email` + - `offline_access` + - `User.Read` + - `Mail.ReadWrite` + - `Mail.Send` + - `IMAP.AccessAsUser.All` + - `SMTP.Send` + +2. Click **Grant admin consent for Default Directory** to grant consent for the scopes being added to the app. + +### Platform Configuration + +Now, we will register our app as a Web application to be able to use it as an authentication endpoint later. + +At the left App Menu, go to **Authentication > Platform Configurations > Add a platform** and select the `Web` platform. This is important because it will enable us to pass in a client secret to our app's endpoint. + +#### Redirect URIs + +Add the following Redirect URI to the Web platform: + +- `http://localhost:8189` + +### Setting Up a Client Secret + +We need a Client Secret in order to use OAuth. Click on **Certificates and Secrets > Client Secrets > New Client Secret** and follow the directives to create a new client secret. + +Record the value of your client secret and keep it safe, because it won't be shown to you later. Your client secret will also expire on the date that you set it to, so you will have to repeat this process at that time. + +Finally, go to **Overview** and record the value of your **Application (client) ID**. + +# Using Matcha + +We are now ready to authenticate to our newly-created App. + +### 1. Save your client credentials + +Create the file `~/.config/matcha/oauth_client_outlook.json`: + +```json +{ + "client_id": "YOUR_APPLICATION_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET_VALUE" +} +``` + +### 2. Authentication + +Run `matcha oauth auth yourname@email.com` and go to the `http://localhost:8189` as prompted. Authenticate with your personal Outlook account, and authorize your Entra app to access your account. + +Once authorized, you'll see "Authorization complete!" in your terminal. A token will be stored that lets you authenticate via XOauth2. + +Or, if you don't want to create the JSON file, run with inline credentials: + +```bash +matcha oauth auth your@outlook.com --provider outlook --client-id YOUR_ID --client-secret YOUR_SECRET +``` + +### Enabling IMAP in Outlook + +We are almost ready to connect with Matcha. First, we must enable IMAP access in Outlook if you have not done so already. + +Go to [outlook.com](https://outlook.com) and access your personal Inbox. Then: + +1. Select **Settings > Mail > Forwarding and IMAP**. +2. Under POP and IMAP, toggle the slider for **Let devices and apps use IMAP**. +3. Select Save. + +### 3. Add your account in Matcha + +From Matcha, open settings and choose to add a new account. Enter: + +- **Provider**: outlook +- **Display name**: The name that will appear on emails you send +- **Username**: Your Outlook email address +- **Email Address**: The email address to fetch messages from (usually the same) +- **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 oauth token your@outlook.com + +# Revoke and delete stored tokens +matcha oauth revoke your@outlook.com + +# Re-authorize +matcha oauth auth your@outlook.com +``` + +--- + +## Alternative: App Password + +If you prefer not to set up OAuth2, you can use an app password. App passwords are available for Microsoft accounts with two-step verification enabled. + +### 1. Enable two-step verification + +1. Go to [https://account.microsoft.com/security](https://account.microsoft.com/security). +2. Under **Two-step verification**, click **Turn on** if not already enabled. + +### 2. Create an App Password + +1. Go to [https://account.live.com/proofs/manage/additional](https://account.live.com/proofs/manage/additional). +2. Under **App passwords**, click **Create a new app password**. +3. Copy the generated password. + +### 3. Add your account in Matcha + +From Matcha, open settings and choose to add a new account. Enter: + +- **Provider**: outlook +- **Display name**: The name that will appear on emails you send +- **Username**: Your Outlook email address +- **Email Address**: The email address to fetch messages from +- **Password**: The generated app password (not your regular Microsoft password) + +--- + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| **Authentication error with parser message** | Outlook's IMAP error responses can trip up some IMAP parsers. Use OAuth2 instead of app passwords if you see wire-parsing errors. | +| **OAuth2: consent screen not showing permissions** | Ensure you added the correct API permissions (IMAP.AccessAsUser.All, SMTP.Send, offline_access) in Azure. | +| **OAuth2: token expired** | Run `matcha oauth auth your@outlook.com` to re-authorize. | +| **OAuth2: refresh failed** | Your client secret may have expired. Create a new one in Azure and update `oauth_client_outlook.json`. | +| **"python3 not found"** | OAuth2 requires Python 3. Install it via your package manager. | +| **App password not available** | App passwords require two-step verification to be enabled on your Microsoft account. | diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 60b47f4cae5bba84304708ad3abc899606bc4933..7f8d7fc9d1f248d0accc24e7495b491270e6de4c 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -20,8 +20,8 @@ import ( "time" "github.com/ProtonMail/go-crypto/openpgp" - "github.com/emersion/go-imap" - "github.com/emersion/go-imap/client" + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" "github.com/emersion/go-message/mail" "github.com/emersion/go-pgpmail" "github.com/floatpane/matcha/config" @@ -68,33 +68,25 @@ type Folder struct { Attributes []string } -// formatAddress returns "Name " when a PersonalName is present, +// formatAddress returns "Name " when a Name is present, // otherwise just "email". -func formatAddress(addr *imap.Address) string { - email := addr.Address() - if addr.PersonalName != "" { - return addr.PersonalName + " <" + email + ">" +func formatAddress(addr imap.Address) string { + email := addr.Addr() + if addr.Name != "" { + return addr.Name + " <" + email + ">" } return email } -func hasSeenFlag(flags []string) bool { - return slices.Contains(flags, imap.SeenFlag) +func hasSeenFlag(flags []imap.Flag) bool { + return slices.Contains(flags, imap.FlagSeen) } // deliveryHeadersMatch checks if any of the Delivered-To, X-Forwarded-To, or // X-Original-To headers contain the given email address. This catches // auto-forwarded emails where the envelope To/Cc don't match the local account. -func deliveryHeadersMatch(msg *imap.Message, section *imap.BodySectionName, fetchEmail string) bool { - if section == nil { - return false - } - literal := msg.GetBody(section) - if literal == nil { - return false - } - data, err := ioutil.ReadAll(literal) - if err != nil || len(data) == 0 { +func deliveryHeadersMatch(data []byte, fetchEmail string) bool { + if len(data) == 0 { return false } // Parse as MIME headers @@ -171,7 +163,67 @@ func decodeAttachmentData(rawBytes []byte, encoding string) ([]byte, error) { } } -func connect(account *config.Account) (*client.Client, error) { +// parsePartID converts a string part ID like "1.2.3" to []int{1, 2, 3}. +// Special cases: "TEXT" maps to empty with PartSpecifierText (handled by caller). +func parsePartID(partID string) []int { + if partID == "" || partID == "TEXT" { + return nil + } + var parts []int + for _, s := range strings.Split(partID, ".") { + n := 0 + for _, c := range s { + if c >= '0' && c <= '9' { + n = n*10 + int(c-'0') + } + } + parts = append(parts, n) + } + return parts +} + +// formatPartPath converts a Walk path like []int{1, 2, 3} to "1.2.3". +func formatPartPath(path []int) string { + if len(path) == 0 { + return "1" + } + parts := make([]string, len(path)) + for i, p := range path { + parts[i] = fmt.Sprintf("%d", p) + } + return strings.Join(parts, ".") +} + +// getBodyStructureBoundary extracts the boundary parameter from a multipart body structure. +func getBodyStructureBoundary(bs imap.BodyStructure) string { + if mp, ok := bs.(*imap.BodyStructureMultiPart); ok { + if mp.Extended != nil && mp.Extended.Params != nil { + return mp.Extended.Params["boundary"] + } + } + return "" +} + +// uidsToUIDSet converts a slice of uint32 UIDs to an imap.UIDSet. +func uidsToUIDSet(uids []uint32) imap.UIDSet { + var uidSet imap.UIDSet + for _, uid := range uids { + uidSet.AddNum(imap.UID(uid)) + } + return uidSet +} + +func connectWithHandler(account *config.Account, handler *imapclient.UnilateralDataHandler) (*imapclient.Client, error) { + return connectWithOptions(account, &imapclient.Options{ + UnilateralDataHandler: handler, + }) +} + +func connect(account *config.Account) (*imapclient.Client, error) { + return connectWithOptions(account, nil) +} + +func connectWithOptions(account *config.Account, extraOpts *imapclient.Options) (*imapclient.Client, error) { imapServer := account.GetIMAPServer() imapPort := account.GetIMAPPort() @@ -181,31 +233,44 @@ func connect(account *config.Account) (*client.Client, error) { addr := fmt.Sprintf("%s:%d", imapServer, imapPort) - tlsConfig := &tls.Config{ - ServerName: imapServer, - InsecureSkipVerify: account.Insecure, + options := &imapclient.Options{ + TLSConfig: &tls.Config{ + ServerName: imapServer, + InsecureSkipVerify: account.Insecure, + }, + } + if extraOpts != nil { + options.UnilateralDataHandler = extraOpts.UnilateralDataHandler + options.DebugWriter = extraOpts.DebugWriter + } + if path := os.Getenv("DEBUG_IMAP"); path != "" { + if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600); err == nil { + options.DebugWriter = f + } } - var c *client.Client + var c *imapclient.Client var err error - // If using standard non-implicit ports (1143 or 143), use Dial + STARTTLS + // If using standard non-implicit ports (1143 or 143), use DialStartTLS if imapPort == 1143 || imapPort == 143 { - c, err = client.Dial(addr) + c, err = imapclient.DialStartTLS(addr, options) if err != nil { return nil, err } - if err := c.StartTLS(tlsConfig); err != nil { - return nil, err - } } else { // Otherwise default to implicit TLS (port 993) - c, err = client.DialTLS(addr, tlsConfig) + c, err = imapclient.DialTLS(addr, options) if err != nil { return nil, err } } + if err := c.WaitGreeting(); err != nil { + c.Close() + return nil, err + } + // Authenticate using OAuth2 (XOAUTH2) or plain password if account.IsOAuth2() { token, err := config.GetOAuth2Token(account.Email) @@ -216,8 +281,8 @@ func connect(account *config.Account) (*client.Client, error) { return nil, fmt.Errorf("XOAUTH2 authentication failed: %w", err) } } else { - if err := c.Login(account.Email, account.Password); err != nil { - return nil, fmt.Errorf("authentication error") + if err := c.Login(account.Email, account.Password).Wait(); err != nil { + return nil, fmt.Errorf("authentication error: %w", err) } } @@ -228,6 +293,8 @@ func getSentMailbox(account *config.Account) string { switch account.ServiceProvider { case "gmail": return "[Gmail]/Sent Mail" + case "outlook": + return "Sent Items" case "icloud": return "Sent Messages" default: @@ -236,24 +303,25 @@ func getSentMailbox(account *config.Account) string { } // getMailboxByAttr finds a mailbox with the given IMAP attribute (e.g., \All, \Sent, \Trash). -func getMailboxByAttr(c *client.Client, attr string) (string, error) { - mailboxes := make(chan *imap.MailboxInfo, 10) - done := make(chan error, 1) - go func() { - done <- c.List("", "*", mailboxes) - }() +func getMailboxByAttr(c *imapclient.Client, attr imap.MailboxAttr) (string, error) { + listCmd := c.List("", "*", nil) + defer listCmd.Close() var foundMailbox string - for m := range mailboxes { - for _, a := range m.Attributes { + for { + data := listCmd.Next() + if data == nil { + break + } + for _, a := range data.Attrs { if a == attr { - foundMailbox = m.Name + foundMailbox = data.Mailbox break } } } - if err := <-done; err != nil { + if err := listCmd.Close(); err != nil { return "", err } @@ -269,14 +337,14 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u if err != nil { return nil, err } - defer c.Logout() + defer c.Close() - mbox, err := c.Select(mailbox, false) + selectData, err := c.Select(mailbox, nil).Wait() if err != nil { return nil, err } - if mbox.Messages == 0 { + if selectData.NumMessages == 0 { return []Email{}, nil } @@ -284,8 +352,8 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u // Start from the top minus offset cursor := uint32(0) - if mbox.Messages > offset { - cursor = mbox.Messages - offset + if selectData.NumMessages > offset { + cursor = selectData.NumMessages - offset } else { return []Email{}, nil } @@ -297,10 +365,16 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u } isSentMailbox := mailbox == getSentMailbox(account) + // Delivery header section for matching auto-forwarded emails + deliveryHeaderSection := &imap.FetchItemBodySection{ + Specifier: imap.PartSpecifierHeader, + HeaderFields: []string{"Delivered-To", "X-Forwarded-To", "X-Original-To"}, + Peek: true, + } + // Loop until we have enough emails or run out of messages for len(allEmails) < int(limit) && cursor > 0 { // Determine chunk size - // Fetch at least 'limit' or 50 messages to reduce round trips chunkSize := limit if chunkSize < 50 { chunkSize = 50 @@ -311,33 +385,25 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u from = cursor - uint32(chunkSize) + 1 } - seqset := new(imap.SeqSet) + var seqset imap.SeqSet seqset.AddRange(from, cursor) - messages := make(chan *imap.Message, chunkSize) - done := make(chan error, 1) - // Fetch delivery headers to match auto-forwarded emails (Delivered-To, X-Forwarded-To, X-Original-To) - deliveryHeaderItem := imap.FetchItem("BODY.PEEK[HEADER.FIELDS (Delivered-To X-Forwarded-To X-Original-To)]") - deliveryHeaderSection, _ := imap.ParseBodySectionName(deliveryHeaderItem) - fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchFlags, deliveryHeaderItem} - - go func() { - done <- c.Fetch(seqset, fetchItems, messages) - }() - - var batchMsgs []*imap.Message - for msg := range messages { - batchMsgs = append(batchMsgs, msg) - } + fetchCmd := c.Fetch(seqset, &imap.FetchOptions{ + Envelope: true, + UID: true, + Flags: true, + BodySection: []*imap.FetchItemBodySection{deliveryHeaderSection}, + }) - if err := <-done; err != nil { + batchMsgs, err := fetchCmd.Collect() + if err != nil { return nil, err } // Filter messages in this batch var batchEmails []Email for _, msg := range batchMsgs { - if msg == nil || msg.Envelope == nil { + if msg.Envelope == nil { continue } @@ -348,17 +414,17 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u var toAddrList []string for _, addr := range msg.Envelope.To { - toAddrList = append(toAddrList, addr.Address()) + toAddrList = append(toAddrList, addr.Addr()) } for _, addr := range msg.Envelope.Cc { - toAddrList = append(toAddrList, addr.Address()) + toAddrList = append(toAddrList, addr.Addr()) } matched := false if isSentMailbox { var senderEmail string if len(msg.Envelope.From) > 0 { - senderEmail = msg.Envelope.From[0].Address() + senderEmail = msg.Envelope.From[0].Addr() } if strings.EqualFold(strings.TrimSpace(senderEmail), fetchEmail) { matched = true @@ -372,7 +438,8 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u } // Check delivery headers for auto-forwarded emails if !matched { - matched = deliveryHeadersMatch(msg, deliveryHeaderSection, fetchEmail) + headerData := msg.FindBodySection(deliveryHeaderSection) + matched = deliveryHeadersMatch(headerData, fetchEmail) } } @@ -381,7 +448,7 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u } batchEmails = append(batchEmails, Email{ - UID: msg.Uid, + UID: uint32(msg.UID), From: fromAddr, To: toAddrList, Subject: decodeHeader(msg.Envelope.Subject), @@ -391,11 +458,7 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u }) } - // Sort batch Newest -> Oldest (since IMAP usually returns Oldest->Newest or arbitrary) - // Assuming seqset order or standard behavior, we want to ensure we append Newest emails first - // so that the final list is correct. - // Actually, let's just sort the batch by UID desc (Newest first) - // Simple bubble sort for small batch + // Sort batch Newest -> Oldest by UID desc for i := 0; i < len(batchEmails); i++ { for j := i + 1; j < len(batchEmails); j++ { if batchEmails[j].UID > batchEmails[i].UID { @@ -404,10 +467,7 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u } } - // Append to allEmails allEmails = append(allEmails, batchEmails...) - - // Update cursor for next iteration cursor = from - 1 } @@ -424,97 +484,82 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint if err != nil { return "", nil, err } - defer c.Logout() + defer c.Close() - if _, err := c.Select(mailbox, false); err != nil { + if _, err := c.Select(mailbox, nil).Wait(); err != nil { return "", nil, err } - seqset := new(imap.SeqSet) - seqset.AddNum(uid) + uidSet := imap.UIDSetNum(imap.UID(uid)) fetchWholeMessage := func() ([]byte, error) { - fetchItem := imap.FetchItem("BODY.PEEK[]") - section, _ := imap.ParseBodySectionName(fetchItem) - partMessages := make(chan *imap.Message, 1) - partDone := make(chan error, 1) - go func() { - partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages) - }() - if err := <-partDone; err != nil { + wholeSection := &imap.FetchItemBodySection{Peek: true} + fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{ + BodySection: []*imap.FetchItemBodySection{wholeSection}, + }) + msgs, err := fetchCmd.Collect() + if err != nil { return nil, err } - partMsg := <-partMessages - if partMsg != nil { - literal := partMsg.GetBody(section) - if literal != nil { - return ioutil.ReadAll(literal) + if len(msgs) > 0 { + if data := msgs[0].FindBodySection(wholeSection); data != nil { + return data, nil } } return nil, fmt.Errorf("could not fetch whole message") } fetchInlinePart := func(partID, encoding string) ([]byte, error) { - fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID)) - section, err := imap.ParseBodySectionName(fetchItem) - if err != nil { - return nil, err + part := parsePartID(partID) + section := &imap.FetchItemBodySection{ + Part: part, + Peek: true, } - partMessages := make(chan *imap.Message, 1) - partDone := make(chan error, 1) - go func() { - partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages) - }() - - if err := <-partDone; err != nil { + fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{ + BodySection: []*imap.FetchItemBodySection{section}, + }) + msgs, err := fetchCmd.Collect() + if err != nil { return nil, err } - partMsg := <-partMessages - if partMsg == nil { + if len(msgs) == 0 { return nil, fmt.Errorf("could not fetch inline part %s", partID) } - literal := partMsg.GetBody(section) - if literal == nil { + rawBytes := msgs[0].FindBodySection(section) + if rawBytes == nil { return nil, fmt.Errorf("could not get inline part body %s", partID) } - rawBytes, err := ioutil.ReadAll(literal) - if err != nil { - return nil, err - } - return decodeAttachmentData(rawBytes, encoding) } - messages := make(chan *imap.Message, 1) - done := make(chan error, 1) - fetchItems := []imap.FetchItem{imap.FetchBodyStructure} - go func() { - done <- c.UidFetch(seqset, fetchItems, messages) - }() - - if err := <-done; err != nil { + fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{ + BodyStructure: &imap.FetchItemBodyStructure{Extended: true}, + }) + bsMsgs, err := fetchCmd.Collect() + if err != nil { return "", nil, err } - msg := <-messages - if msg == nil || msg.BodyStructure == nil { + if len(bsMsgs) == 0 || bsMsgs[0].BodyStructure == nil { return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid) } + msg := bsMsgs[0] + var plainPartID, plainPartEncoding string var htmlPartID, htmlPartEncoding string var attachments []Attachment var extractedBody string // Used if we intercept and decrypt a payload - var checkPart func(part *imap.BodyStructure, partID string) - checkPart = func(part *imap.BodyStructure, partID string) { + var checkPart func(part *imap.BodyStructureSinglePart, partID string) + checkPart = func(part *imap.BodyStructureSinglePart, partID string) { // Check for text content (prefer html over plain) - if part.MIMEType == "text" { - sub := strings.ToLower(part.MIMESubType) + if strings.EqualFold(part.Type, "text") { + sub := strings.ToLower(part.Subtype) switch sub { case "html": if htmlPartID == "" { @@ -530,17 +575,7 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint } // Check for attachments using multiple methods - filename := "" - // First try the Filename() method which handles various cases - if fn, err := part.Filename(); err == nil && fn != "" { - filename = fn - } - // Fallback: check DispositionParams - if filename == "" { - if fn, ok := part.DispositionParams["filename"]; ok && fn != "" { - filename = fn - } - } + filename := part.Filename() // Fallback: check Params (for name parameter) if filename == "" { if fn, ok := part.Params["name"]; ok && fn != "" { @@ -556,10 +591,17 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint // Add as attachment if it has a disposition or a filename (and not just plain text). // Allow inline parts without filenames (common for cid images). - contentID := strings.Trim(part.Id, "<>") - mimeType := fmt.Sprintf("%s/%s", strings.ToLower(part.MIMEType), strings.ToLower(part.MIMESubType)) + contentID := strings.Trim(part.ID, "<>") + mimeType := part.MediaType() + dispValue := "" + dispParams := map[string]string{} + if part.Disposition() != nil { + dispValue = part.Disposition().Value + dispParams = part.Disposition().Params + } + _ = dispParams // used below in attachment fallback checks isCID := contentID != "" - isInline := part.Disposition == "inline" || isCID + isInline := strings.EqualFold(dispValue, "inline") || isCID if filename == "" && isInline && strings.HasPrefix(mimeType, "image/") { filename = "inline" @@ -721,7 +763,7 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint att.Data = data p7, err := pkcs7.Parse(data) if err == nil { - boundary := msg.BodyStructure.Params["boundary"] + boundary := getBodyStructureBoundary(msg.BodyStructure) if boundary != "" { rawEmail, err := fetchWholeMessage() if err == nil { @@ -777,7 +819,7 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint } // === PGP ENCRYPTED MESSAGE DETECTION === - if mimeType == "application/pgp-encrypted" || (mimeType == "multipart/encrypted" && strings.Contains(part.MIMESubType, "pgp")) { + if mimeType == "application/pgp-encrypted" || (mimeType == "multipart/encrypted" && strings.Contains(part.Subtype, "pgp")) { // PGP encrypted messages typically have two parts: // 1. Version info (application/pgp-encrypted) // 2. Encrypted data (application/octet-stream) @@ -855,7 +897,7 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint att.Data = data // Try to verify the signature - boundary := msg.BodyStructure.Params["boundary"] + boundary := getBodyStructureBoundary(msg.BodyStructure) if boundary != "" { rawEmail, err := fetchWholeMessage() if err == nil { @@ -890,7 +932,7 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint } } attachments = append(attachments, att) - } else if (filename != "" || isCID) && (part.Disposition == "attachment" || isInline || part.MIMEType != "text") { + } else if (filename != "" || isCID) && (strings.EqualFold(dispValue, "attachment") || isInline || !strings.EqualFold(part.Type, "text")) { att := Attachment{ Filename: filename, PartID: partID, @@ -908,33 +950,14 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint } } - var findParts func(*imap.BodyStructure, string) - findParts = func(bs *imap.BodyStructure, prefix string) { - // If this is a non-multipart message, check the body structure itself - if len(bs.Parts) == 0 { - partID := prefix - if partID == "" { - partID = "1" - } - checkPart(bs, partID) - return + // Walk the body structure tree + msg.BodyStructure.Walk(func(path []int, part imap.BodyStructure) bool { + if sp, ok := part.(*imap.BodyStructureSinglePart); ok { + partID := formatPartPath(path) + checkPart(sp, partID) } - - // Iterate through parts - for i, part := range bs.Parts { - partID := fmt.Sprintf("%d", i+1) - if prefix != "" { - partID = fmt.Sprintf("%s.%d", prefix, i+1) - } - - checkPart(part, partID) - - if len(part.Parts) > 0 { - findParts(part, partID) - } - } - } - findParts(msg.BodyStructure, "") + return true + }) // If we hijacked and decrypted the body, return it immediately if extractedBody != "" { @@ -962,28 +985,22 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint } } if textPartID != "" { - partMessages := make(chan *imap.Message, 1) - partDone := make(chan error, 1) - - fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", textPartID)) - section, err := imap.ParseBodySectionName(fetchItem) - if err != nil { - return "", nil, err + part := parsePartID(textPartID) + section := &imap.FetchItemBodySection{ + Part: part, + Peek: true, } - go func() { - partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages) - }() - - if err := <-partDone; err != nil { + fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{ + BodySection: []*imap.FetchItemBodySection{section}, + }) + msgs, err := fetchCmd.Collect() + if err != nil { return "", nil, err } - partMsg := <-partMessages - if partMsg != nil { - literal := partMsg.GetBody(section) - if literal != nil { - buf, _ := ioutil.ReadAll(literal) + if len(msgs) > 0 { + if buf := msgs[0].FindBodySection(section); buf != nil { // Use the encoding from BodyStructure to decode if decoded, err := decodeAttachmentData(buf, textPartEncoding); err == nil { body = string(decoded) @@ -1002,46 +1019,36 @@ func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uin if err != nil { return nil, err } - defer c.Logout() + defer c.Close() - if _, err := c.Select(mailbox, false); err != nil { + if _, err := c.Select(mailbox, nil).Wait(); err != nil { return nil, err } - seqset := new(imap.SeqSet) - seqset.AddNum(uid) - - fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID)) - section, err := imap.ParseBodySectionName(fetchItem) - if err != nil { - return nil, err + uidSet := imap.UIDSetNum(imap.UID(uid)) + part := parsePartID(partID) + section := &imap.FetchItemBodySection{ + Part: part, + Peek: true, } - messages := make(chan *imap.Message, 1) - done := make(chan error, 1) - go func() { - done <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, messages) - }() - - if err := <-done; err != nil { + fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{ + BodySection: []*imap.FetchItemBodySection{section}, + }) + msgs, err := fetchCmd.Collect() + if err != nil { return nil, err } - msg := <-messages - if msg == nil { + if len(msgs) == 0 { return nil, fmt.Errorf("could not fetch attachment") } - literal := msg.GetBody(section) - if literal == nil { + rawBytes := msgs[0].FindBodySection(section) + if rawBytes == nil { return nil, fmt.Errorf("could not get attachment body") } - rawBytes, err := ioutil.ReadAll(literal) - if err != nil { - return nil, err - } - decoded, err := decodeAttachmentData(rawBytes, encoding) if err != nil { return rawBytes, nil @@ -1054,16 +1061,15 @@ func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox s if err != nil { return err } - defer c.Logout() + defer c.Close() - if _, err := c.Select(sourceMailbox, false); err != nil { + if _, err := c.Select(sourceMailbox, nil).Wait(); err != nil { return err } - seqSet := new(imap.SeqSet) - seqSet.AddNum(uid) - - return c.UidMove(seqSet, destMailbox) + uidSet := imap.UIDSetNum(imap.UID(uid)) + _, err = c.Move(uidSet, destMailbox).Wait() + return err } func MarkEmailAsReadInMailbox(account *config.Account, mailbox string, uid uint32) error { @@ -1071,19 +1077,18 @@ func MarkEmailAsReadInMailbox(account *config.Account, mailbox string, uid uint3 if err != nil { return err } - defer c.Logout() + defer c.Close() - if _, err := c.Select(mailbox, false); err != nil { + if _, err := c.Select(mailbox, nil).Wait(); err != nil { return err } - seqSet := new(imap.SeqSet) - seqSet.AddNum(uid) - - item := imap.FormatFlagsOp(imap.AddFlags, true) - flags := []interface{}{imap.SeenFlag} - - return c.UidStore(seqSet, item, flags, nil) + uidSet := imap.UIDSetNum(imap.UID(uid)) + return c.Store(uidSet, &imap.StoreFlags{ + Op: imap.StoreFlagsAdd, + Silent: true, + Flags: []imap.Flag{imap.FlagSeen}, + }, nil).Close() } func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error { @@ -1091,23 +1096,22 @@ func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) if err != nil { return err } - defer c.Logout() + defer c.Close() - if _, err := c.Select(mailbox, false); err != nil { + if _, err := c.Select(mailbox, nil).Wait(); err != nil { return err } - seqSet := new(imap.SeqSet) - seqSet.AddNum(uid) - - item := imap.FormatFlagsOp(imap.AddFlags, true) - flags := []interface{}{imap.DeletedFlag} - - if err := c.UidStore(seqSet, item, flags, nil); err != nil { + uidSet := imap.UIDSetNum(imap.UID(uid)) + if err := c.Store(uidSet, &imap.StoreFlags{ + Op: imap.StoreFlagsAdd, + Silent: true, + Flags: []imap.Flag{imap.FlagDeleted}, + }, nil).Close(); err != nil { return err } - return c.Expunge(nil) + return c.Expunge().Close() } func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error { @@ -1115,13 +1119,13 @@ func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32 if err != nil { return err } - defer c.Logout() + defer c.Close() var archiveMailbox string switch account.ServiceProvider { case "gmail": // For Gmail, find the mailbox with the \All attribute - archiveMailbox, err = getMailboxByAttr(c, imap.AllAttr) + archiveMailbox, err = getMailboxByAttr(c, imap.MailboxAttrAll) if err != nil { // Fallback to hardcoded path if attribute lookup fails archiveMailbox = "[Gmail]/All Mail" @@ -1130,14 +1134,13 @@ func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32 archiveMailbox = "Archive" } - if _, err := c.Select(mailbox, false); err != nil { + if _, err := c.Select(mailbox, nil).Wait(); err != nil { return err } - seqSet := new(imap.SeqSet) - seqSet.AddNum(uid) - - return c.UidMove(seqSet, archiveMailbox) + uidSet := imap.UIDSetNum(imap.UID(uid)) + _, err = c.Move(uidSet, archiveMailbox).Wait() + return err } // Batch operations for multiple emails @@ -1152,25 +1155,22 @@ func DeleteEmailsFromMailbox(account *config.Account, mailbox string, uids []uin if err != nil { return err } - defer c.Logout() + defer c.Close() - if _, err := c.Select(mailbox, false); err != nil { + if _, err := c.Select(mailbox, nil).Wait(); err != nil { return err } - seqSet := new(imap.SeqSet) - for _, uid := range uids { - seqSet.AddNum(uid) - } - - item := imap.FormatFlagsOp(imap.AddFlags, true) - flags := []interface{}{imap.DeletedFlag} - - if err := c.UidStore(seqSet, item, flags, nil); err != nil { + uidSet := uidsToUIDSet(uids) + if err := c.Store(uidSet, &imap.StoreFlags{ + Op: imap.StoreFlagsAdd, + Silent: true, + Flags: []imap.Flag{imap.FlagDeleted}, + }, nil).Close(); err != nil { return err } - return c.Expunge(nil) + return c.Expunge().Close() } // ArchiveEmailsFromMailbox archives multiple emails from a mailbox (batch operation) @@ -1183,12 +1183,12 @@ func ArchiveEmailsFromMailbox(account *config.Account, mailbox string, uids []ui if err != nil { return err } - defer c.Logout() + defer c.Close() var archiveMailbox string switch account.ServiceProvider { case "gmail": - archiveMailbox, err = getMailboxByAttr(c, imap.AllAttr) + archiveMailbox, err = getMailboxByAttr(c, imap.MailboxAttrAll) if err != nil { archiveMailbox = "[Gmail]/All Mail" } @@ -1196,16 +1196,13 @@ func ArchiveEmailsFromMailbox(account *config.Account, mailbox string, uids []ui archiveMailbox = "Archive" } - if _, err := c.Select(mailbox, false); err != nil { + if _, err := c.Select(mailbox, nil).Wait(); err != nil { return err } - seqSet := new(imap.SeqSet) - for _, uid := range uids { - seqSet.AddNum(uid) - } - - return c.UidMove(seqSet, archiveMailbox) + uidSet := uidsToUIDSet(uids) + _, err = c.Move(uidSet, archiveMailbox).Wait() + return err } // MoveEmailsToFolder moves multiple emails to a different folder (batch operation) @@ -1218,18 +1215,15 @@ func MoveEmailsToFolder(account *config.Account, uids []uint32, sourceFolder, de if err != nil { return err } - defer c.Logout() + defer c.Close() - if _, err := c.Select(sourceFolder, false); err != nil { + if _, err := c.Select(sourceFolder, nil).Wait(); err != nil { return err } - seqSet := new(imap.SeqSet) - for _, uid := range uids { - seqSet.AddNum(uid) - } - - return c.UidMove(seqSet, destFolder) + uidSet := uidsToUIDSet(uids) + _, err = c.Move(uidSet, destFolder).Wait() + return err } // Convenience wrappers defaulting to INBOX for existing call sites. @@ -1275,16 +1269,26 @@ func ArchiveSentEmail(account *config.Account, uid uint32) error { } // AppendToSentMailbox appends a raw RFC822 message to the Sent mailbox via IMAP APPEND. -func AppendToSentMailbox(account *config.Account, msg []byte) error { +func AppendToSentMailbox(account *config.Account, rawMsg []byte) error { c, err := connect(account) if err != nil { return err } - defer c.Logout() + defer c.Close() sentMailbox := getSentMailbox(account) - flags := []string{imap.SeenFlag} - return c.Append(sentMailbox, flags, time.Now(), bytes.NewReader(msg)) + appendCmd := c.Append(sentMailbox, int64(len(rawMsg)), &imap.AppendOptions{ + Flags: []imap.Flag{imap.FlagSeen}, + Time: time.Now(), + }) + if _, err := appendCmd.Write(rawMsg); err != nil { + return err + } + if err := appendCmd.Close(); err != nil { + return err + } + _, err = appendCmd.Wait() + return err } // getTrashMailbox returns the trash mailbox name for the account @@ -1292,6 +1296,8 @@ func getTrashMailbox(account *config.Account) string { switch account.ServiceProvider { case "gmail": return "[Gmail]/Trash" + case "outlook": + return "Deleted Items" case "icloud": return "Deleted Messages" default: @@ -1304,7 +1310,7 @@ func getArchiveMailbox(account *config.Account) string { switch account.ServiceProvider { case "gmail": return "[Gmail]/All Mail" - case "icloud": + case "outlook", "icloud": return "Archive" default: return "Archive" @@ -1317,10 +1323,10 @@ func FetchTrashEmails(account *config.Account, limit, offset uint32) ([]Email, e if err != nil { return nil, err } - defer c.Logout() + defer c.Close() // Try to find trash by attribute first - trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr) + trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash) if err != nil { // Fallback to hardcoded path trashMailbox = getTrashMailbox(account) @@ -1336,25 +1342,25 @@ func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, if err != nil { return nil, err } - defer c.Logout() + defer c.Close() // Try to find archive by attribute first (Gmail uses \All) - archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr) + archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll) if err != nil { // Fallback to hardcoded path archiveMailbox = getArchiveMailbox(account) } - mbox, err := c.Select(archiveMailbox, false) + selectData, err := c.Select(archiveMailbox, nil).Wait() if err != nil { return nil, err } - if mbox.Messages == 0 { + if selectData.NumMessages == 0 { return []Email{}, nil } - to := mbox.Messages - offset + to := selectData.NumMessages - offset from := uint32(1) if to > limit { from = to - limit + 1 @@ -1364,25 +1370,24 @@ func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, return []Email{}, nil } - seqset := new(imap.SeqSet) + var seqset imap.SeqSet seqset.AddRange(from, to) - messages := make(chan *imap.Message, limit) - done := make(chan error, 1) - // Fetch delivery headers to match auto-forwarded emails - deliveryHeaderItem := imap.FetchItem("BODY.PEEK[HEADER.FIELDS (Delivered-To X-Forwarded-To X-Original-To)]") - deliveryHeaderSection, _ := imap.ParseBodySectionName(deliveryHeaderItem) - fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchFlags, deliveryHeaderItem} - go func() { - done <- c.Fetch(seqset, fetchItems, messages) - }() - - var msgs []*imap.Message - for msg := range messages { - msgs = append(msgs, msg) + // Delivery header section for matching auto-forwarded emails + deliveryHeaderSection := &imap.FetchItemBodySection{ + Specifier: imap.PartSpecifierHeader, + HeaderFields: []string{"Delivered-To", "X-Forwarded-To", "X-Original-To"}, + Peek: true, } - if err := <-done; err != nil { + fetchCmd := c.Fetch(seqset, &imap.FetchOptions{ + Envelope: true, + UID: true, + Flags: true, + BodySection: []*imap.FetchItemBodySection{deliveryHeaderSection}, + }) + msgs, err := fetchCmd.Collect() + if err != nil { return nil, err } @@ -1394,7 +1399,7 @@ func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, var emails []Email for _, msg := range msgs { - if msg == nil || msg.Envelope == nil { + if msg.Envelope == nil { continue } @@ -1405,10 +1410,10 @@ func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, var toAddrList []string for _, addr := range msg.Envelope.To { - toAddrList = append(toAddrList, addr.Address()) + toAddrList = append(toAddrList, addr.Addr()) } for _, addr := range msg.Envelope.Cc { - toAddrList = append(toAddrList, addr.Address()) + toAddrList = append(toAddrList, addr.Addr()) } // For archive/All Mail, match emails where user is sender OR recipient @@ -1428,7 +1433,8 @@ func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, } // Check delivery headers for auto-forwarded emails if !matched { - matched = deliveryHeadersMatch(msg, deliveryHeaderSection, fetchEmail) + headerData := msg.FindBodySection(deliveryHeaderSection) + matched = deliveryHeadersMatch(headerData, fetchEmail) } if !matched { @@ -1436,7 +1442,7 @@ func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, } emails = append(emails, Email{ - UID: msg.Uid, + UID: uint32(msg.UID), From: fromAddr, To: toAddrList, Subject: decodeHeader(msg.Envelope.Subject), @@ -1460,9 +1466,9 @@ func FetchTrashEmailBody(account *config.Account, uid uint32) (string, []Attachm if err != nil { return "", nil, err } - defer c.Logout() + defer c.Close() - trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr) + trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash) if err != nil { trashMailbox = getTrashMailbox(account) } @@ -1476,9 +1482,9 @@ func FetchArchiveEmailBody(account *config.Account, uid uint32) (string, []Attac if err != nil { return "", nil, err } - defer c.Logout() + defer c.Close() - archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr) + archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll) if err != nil { archiveMailbox = getArchiveMailbox(account) } @@ -1492,9 +1498,9 @@ func FetchTrashAttachment(account *config.Account, uid uint32, partID string, en if err != nil { return nil, err } - defer c.Logout() + defer c.Close() - trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr) + trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash) if err != nil { trashMailbox = getTrashMailbox(account) } @@ -1508,9 +1514,9 @@ func FetchArchiveAttachment(account *config.Account, uid uint32, partID string, if err != nil { return nil, err } - defer c.Logout() + defer c.Close() - archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr) + archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll) if err != nil { archiveMailbox = getArchiveMailbox(account) } @@ -1524,9 +1530,9 @@ func DeleteTrashEmail(account *config.Account, uid uint32) error { if err != nil { return err } - defer c.Logout() + defer c.Close() - trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr) + trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash) if err != nil { trashMailbox = getTrashMailbox(account) } @@ -1540,9 +1546,9 @@ func DeleteArchiveEmail(account *config.Account, uid uint32) error { if err != nil { return err } - defer c.Logout() + defer c.Close() - archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr) + archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll) if err != nil { archiveMailbox = getArchiveMailbox(account) } @@ -1556,24 +1562,33 @@ func FetchFolders(account *config.Account) ([]Folder, error) { if err != nil { return nil, err } - defer c.Logout() + defer c.Close() - mailboxes := make(chan *imap.MailboxInfo, 50) - done := make(chan error, 1) - go func() { - done <- c.List("", "*", mailboxes) - }() + listCmd := c.List("", "*", nil) + defer listCmd.Close() var folders []Folder - for m := range mailboxes { + for { + data := listCmd.Next() + if data == nil { + break + } + delim := "" + if data.Delim != 0 { + delim = string(data.Delim) + } + var attrs []string + for _, a := range data.Attrs { + attrs = append(attrs, string(a)) + } folders = append(folders, Folder{ - Name: m.Name, - Delimiter: m.Delimiter, - Attributes: m.Attributes, + Name: data.Mailbox, + Delimiter: delim, + Attributes: attrs, }) } - if err := <-done; err != nil { + if err := listCmd.Close(); err != nil { return nil, err } diff --git a/fetcher/idle.go b/fetcher/idle.go index f75293be6e893271b8db3bcd0e66b8ed6d5a4eb7..a2fc0fbde0321b5f8d12f508f8385ef495fae650 100644 --- a/fetcher/idle.go +++ b/fetcher/idle.go @@ -6,7 +6,7 @@ import ( "sync" "time" - "github.com/emersion/go-imap/client" + "github.com/emersion/go-imap/v2/imapclient" "github.com/floatpane/matcha/config" ) @@ -155,61 +155,59 @@ func (a *accountIdle) run() { // idleOnce connects, selects the mailbox, and runs IDLE until an error or stop. // Returns nil if stopped cleanly. func (a *accountIdle) idleOnce() error { - c, err := connect(a.account) + mailboxUpdates := make(chan uint32, 32) + c, err := connectWithHandler(a.account, &imapclient.UnilateralDataHandler{ + Mailbox: func(data *imapclient.UnilateralDataMailbox) { + if data.NumMessages != nil { + mailboxUpdates <- *data.NumMessages + } + }, + }) if err != nil { return err } - defer func() { - _ = c.Logout() - }() + defer c.Close() // Select the mailbox in read-only mode - mbox, err := c.Select(a.folder, true) + selectData, err := c.Select(a.folder, nil).Wait() if err != nil { return err } - prevExists := mbox.Messages - - // Set up update channel - updates := make(chan client.Update, 32) - c.Updates = updates + prevExists := selectData.NumMessages - // Run IDLE in a goroutine - idleDone := make(chan error, 1) - idleStop := make(chan struct{}) - go func() { - idleDone <- c.Idle(idleStop, nil) - }() + // Start IDLE + idleCmd, err := c.Idle() + if err != nil { + return err + } for { select { case <-a.stop: - close(idleStop) - <-idleDone + idleCmd.Close() + idleCmd.Wait() return nil - case update := <-updates: - switch u := update.(type) { - case *client.MailboxUpdate: - newExists := u.Mailbox.Messages - if newExists > prevExists { - // New mail arrived - select { - case a.notify <- IdleUpdate{ - AccountID: a.account.ID, - FolderName: a.folder, - }: - case <-a.stop: - close(idleStop) - <-idleDone - return nil - } + case newExists := <-mailboxUpdates: + if newExists > prevExists { + select { + case a.notify <- IdleUpdate{ + AccountID: a.account.ID, + FolderName: a.folder, + }: + case <-a.stop: + idleCmd.Close() + idleCmd.Wait() + return nil } - prevExists = newExists } + prevExists = newExists - case err := <-idleDone: - return err + case <-c.Closed(): + if err := idleCmd.Close(); err != nil { + return err + } + return idleCmd.Wait() } } } diff --git a/fetcher/xoauth2.go b/fetcher/xoauth2.go index 25a0de381b8f7917536bae68740ee89fbc4ed4ac..2f64d0125d36d8e8a242e6a3bbb4e53c8813e112 100644 --- a/fetcher/xoauth2.go +++ b/fetcher/xoauth2.go @@ -6,7 +6,7 @@ import ( "github.com/emersion/go-sasl" ) -// xoauth2Client implements the XOAUTH2 SASL mechanism for Gmail. +// xoauth2Client implements the XOAUTH2 SASL mechanism for IMAP/SMTP. // See https://developers.google.com/gmail/imap/xoauth2-protocol type xoauth2Client struct { Username string diff --git a/go.mod b/go.mod index 70bf4e3e22b3c540630c5f98f2776df38595af02..52a74b8d73045e7219ba57920828c7ce954584eb 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/ProtonMail/go-crypto v1.4.1 github.com/PuerkitoBio/goquery v1.12.0 github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25 - github.com/emersion/go-imap v1.2.1 + github.com/emersion/go-imap/v2 v2.0.0-beta.8 github.com/emersion/go-message v0.18.2 github.com/emersion/go-pgpmail v0.2.2 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 diff --git a/go.sum b/go.sum index e68cb72be4972d61dd34903b50536f6868cb6df3..30ea1bf7d8f4439b4f7c7603f3cbf09021401ff2 100644 --- a/go.sum +++ b/go.sum @@ -52,15 +52,13 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25 h1:vXmXuiy1tgifTqWAAaU+ESu1goRp4B3fdhemWMMrS4g= github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25/go.mod h1:BkYEeWL6FbT4Ek+TcOBnPzEKnL7kOq2g19tTQXkorHY= -github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= -github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= -github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= +github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug= +github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48= github.com/emersion/go-message v0.17.0/go.mod h1:/9Bazlb1jwUNB0npYYBsdJ2EMOiiyN3m5UVHbY7GoNw= github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= github.com/emersion/go-pgpmail v0.2.2 h1:cO2jwsE0gb8aDdCcVH5Dfe1XV3Rhhw2GVWsmQd3CbaI= github.com/emersion/go-pgpmail v0.2.2/go.mod h1:mRB5P7QKiAuOvcT36tdRZvm7nSt7V+f6jbzzup3HuvU= -github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= @@ -181,7 +179,6 @@ golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= diff --git a/main.go b/main.go index 0550ef8fbdf8478de38bf59b76cb2ad7488b2134..0161ab2f1191b6049ad5d4fa2eb3f5bb76ffbfb8 100644 --- a/main.go +++ b/main.go @@ -341,8 +341,9 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // If OAuth2, launch the authorization flow after saving the account if lastAccount.IsOAuth2() { email := lastAccount.Email + provider := lastAccount.ServiceProvider return m, func() tea.Msg { - err := config.RunOAuth2Flow(email, "", "") + err := config.RunOAuth2Flow(email, provider, "", "") return tui.OAuth2CompleteMsg{Email: email, Err: err} } } @@ -2591,25 +2592,29 @@ 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. +// runOAuthCLI handles the "matcha oauth" subcommand for OAuth2 management. // Usage: // -// matcha gmail auth [--client-id ID --client-secret SECRET] -// matcha gmail token -// matcha gmail revoke -func runGmailOAuthCLI(args []string) { +// matcha oauth auth [--provider gmail|outlook] [--client-id ID --client-secret SECRET] +// matcha oauth token +// matcha oauth revoke +func runOAuthCLI(args []string) { if len(args) < 1 { - fmt.Fprintln(os.Stderr, "Usage: matcha gmail [flags]") + fmt.Fprintln(os.Stderr, "Usage: matcha oauth [flags]") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Commands:") - fmt.Fprintln(os.Stderr, " auth Authorize a Gmail account via OAuth2 (opens browser)") + fmt.Fprintln(os.Stderr, " auth Authorize an email account via OAuth2 (opens browser)") fmt.Fprintln(os.Stderr, " token Print a fresh access token (refreshes automatically)") fmt.Fprintln(os.Stderr, " revoke 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, "Flags for auth:") + fmt.Fprintln(os.Stderr, " --provider gmail|outlook OAuth2 provider (auto-detected from email)") + fmt.Fprintln(os.Stderr, " --client-id ID OAuth2 client ID") + fmt.Fprintln(os.Stderr, " --client-secret SECRET OAuth2 client secret") fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, "Get credentials at: https://console.cloud.google.com/apis/credentials") + fmt.Fprintln(os.Stderr, "Credentials are stored per provider in:") + fmt.Fprintln(os.Stderr, " Gmail: ~/.config/matcha/oauth_client.json") + fmt.Fprintln(os.Stderr, " Outlook: ~/.config/matcha/oauth_client_outlook.json") os.Exit(1) } @@ -3141,9 +3146,10 @@ func main() { os.Exit(0) } - // Gmail OAuth2 CLI subcommand: matcha gmail [flags] - if len(os.Args) > 1 && os.Args[1] == "gmail" { - runGmailOAuthCLI(os.Args[2:]) + // OAuth2 CLI subcommand: matcha oauth [flags] + // "gmail" is kept as an alias for backwards compatibility. + if len(os.Args) > 1 && (os.Args[1] == "oauth" || os.Args[1] == "gmail") { + runOAuthCLI(os.Args[2:]) os.Exit(0) } diff --git a/tui/login.go b/tui/login.go index 6478dfde19e105f0c31489db4132de9eef5f1bda..6eab0f62a82e1d2e5058c2ca5394a376d99c12d5 100644 --- a/tui/login.go +++ b/tui/login.go @@ -60,7 +60,7 @@ func NewLogin(hideTips bool) *Login { t.Focus() t.Prompt = "🌐 > " case inputProvider: - t.Placeholder = "Provider (gmail, icloud, or custom)" + t.Placeholder = "Provider (gmail, outlook, icloud, or custom)" t.Prompt = "🏢 > " case inputName: t.Placeholder = "Display Name" @@ -128,7 +128,7 @@ func (m *Login) protocol() string { func (m *Login) visibleFields() []int { proto := m.protocol() provider := m.inputs[inputProvider].Value() - isGmail := provider == "gmail" + hasOAuth := provider == "gmail" || provider == "outlook" fields := []int{inputProtocol} @@ -143,7 +143,7 @@ func (m *Login) visibleFields() []int { default: // IMAP (default): existing flow fields = append(fields, inputProvider, inputName, inputEmail, inputFetchEmail, inputSendAsEmail) - if isGmail { + if hasOAuth { fields = append(fields, inputAuthMethod) } if !m.useOAuth2 { @@ -303,7 +303,7 @@ func (m *Login) View() tea.View { case inputProtocol: tip = "Choose the protocol: imap (default), jmap, or pop3." case inputProvider: - tip = "Enter your email provider (e.g., gmail, icloud) or 'custom'." + tip = "Enter your email provider (e.g., gmail, outlook, icloud) or 'custom'." case inputName: tip = "The name that will appear on emails you send." case inputEmail: @@ -313,7 +313,7 @@ func (m *Login) View() tea.View { case inputSendAsEmail: tip = "Optional From header override for outgoing email. Leave blank to send as the fetched address." case inputAuthMethod: - tip = "Type 'oauth2' for Gmail OAuth2 or 'password' for app password." + tip = "Type 'oauth2' for OAuth2 or 'password' for app password." case inputPassword: tip = "Your password or an app-specific password if using 2FA." case inputIMAPServer: @@ -369,7 +369,8 @@ func (m *Login) View() tea.View { ) default: // IMAP flow - isGmail := m.inputs[inputProvider].Value() == "gmail" + provider := m.inputs[inputProvider].Value() + hasOAuth := provider == "gmail" || provider == "outlook" views = append(views, m.inputs[inputProvider].View(), m.inputs[inputName].View(), @@ -378,7 +379,7 @@ func (m *Login) View() tea.View { m.inputs[inputSendAsEmail].View(), ) - if isGmail { + if hasOAuth { views = append(views, m.inputs[inputAuthMethod].View()) }