fix: migrate to go-imap v2 (#500)

Drew Smirnoff created

Change summary

config/README.md                  |  16 
config/config.go                  |  10 
config/oauth.go                   |  10 
config/oauth_script.py            | 280 +++++++++++----
docs/docs/Features/CLI.md         |  17 
docs/docs/setup-guides/gmail.md   |  14 
docs/docs/setup-guides/outlook.md | 176 ++++++++++
fetcher/fetcher.go                | 570 ++++++++++++++++----------------
fetcher/idle.go                   |  74 ++--
fetcher/xoauth2.go                |   2 
go.mod                            |   2 
go.sum                            |   7 
main.go                           |  34 +
tui/login.go                      |  15 
14 files changed, 778 insertions(+), 449 deletions(-)

Detailed changes

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=<email>\x01auth=Bearer <token>\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=<email>\x01auth=Bearer <token>\x01\x01` per the XOAUTH2 protocol spec.

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 {

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)
 	}

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   <email> [--client-id ID --client-secret SECRET]
-  gmail_oauth.py token  <email>
-  gmail_oauth.py revoke <email>
+  oauth.py auth   <email> [--provider gmail|outlook] [--client-id ID --client-secret SECRET]
+  oauth.py token  <email>
+  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).
@@ -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"""
             <html><body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
             <h2>Authorization failed</h2>
-            <p>Error: {params['error'][0]}</p>
+            <p>Error: {params["error"][0]}</p>
             </body></html>
-            """.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 <email> --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)

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 <email>     # Authorize a Gmail account (opens browser)
-matcha gmail token <email>    # Print a fresh access token
-matcha gmail revoke <email>   # Revoke and delete stored tokens
+matcha oauth auth <email>                        # Authorize an account (opens browser, auto-detects provider)
+matcha oauth auth <email> --provider outlook     # Specify provider explicitly
+matcha oauth token <email>                       # Print a fresh access token
+matcha oauth revoke <email>                      # 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
 

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`).               |

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. |

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 <email>" when a PersonalName is present,
+// formatAddress returns "Name <email>" 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"

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()
 		}
 	}
 }

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

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

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=

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   <email> [--client-id ID --client-secret SECRET]
-//	matcha gmail token  <email>
-//	matcha gmail revoke <email>
-func runGmailOAuthCLI(args []string) {
+//	matcha oauth auth   <email> [--provider gmail|outlook] [--client-id ID --client-secret SECRET]
+//	matcha oauth token  <email>
+//	matcha oauth revoke <email>
+func runOAuthCLI(args []string) {
 	if len(args) < 1 {
-		fmt.Fprintln(os.Stderr, "Usage: matcha gmail <auth|token|revoke> <email> [flags]")
+		fmt.Fprintln(os.Stderr, "Usage: matcha oauth <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, "  auth   <email>  Authorize an email 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, "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 <auth|token|revoke> <email> [flags]
-	if len(os.Args) > 1 && os.Args[1] == "gmail" {
-		runGmailOAuthCLI(os.Args[2:])
+	// OAuth2 CLI subcommand: matcha oauth <auth|token|revoke> <email> [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)
 	}
 

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())
 		}