From dc3d5dac901d37b46d92782b1c410832384603c4 Mon Sep 17 00:00:00 2001 From: Microck Date: Wed, 9 Sep 2026 21:56:39 +0000 Subject: [PATCH] feat: search and read Kagi Mail from the CLI --- .env.example | 6 + CHANGELOG.md | 4 + Cargo.toml | 2 +- README.md | 24 +- docs/content/docs/commands/mail.mdx | 88 ++ docs/content/docs/commands/meta.json | 1 + docs/content/docs/reference/auth-matrix.mdx | 4 +- skills/kagi-usage/SKILL.md | 20 + src/auth.rs | 75 +- src/cli.rs | 2 + src/error.rs | 4 + src/mail-auth.rs | 502 +++++++++++ src/mail.rs | 489 ++++++++++ src/main.rs | 18 +- tests/mail-cli.rs | 934 ++++++++++++++++++++ 15 files changed, 2161 insertions(+), 12 deletions(-) create mode 100644 docs/content/docs/commands/mail.mdx create mode 100644 src/mail-auth.rs create mode 100644 src/mail.rs create mode 100644 tests/mail-cli.rs diff --git a/.env.example b/.env.example index 9820a06..86f380e 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,9 @@ KAGI_API_TOKEN= # Required for lens-aware search and the proven HTML/session path. KAGI_SESSION_TOKEN= + +# Mail uses separate OAuth credentials. Set private connection details locally. +KAGI_MAIL_ENDPOINT= +KAGI_MAIL_CLIENT_ID= +# Optional existing OAuth bearer token; otherwise run `kagi mail login`. +KAGI_MAIL_ACCESS_TOKEN= diff --git a/CHANGELOG.md b/CHANGELOG.md index c143c9f..dd0a613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ ## [Unreleased] +### Added + +- `kagi mail` lists mailboxes, searches exact text or meaning, and reads messages or threads through the mail MCP. Separate OAuth device login, automatic token refresh, profiles, and JSON/compact/TOON/pretty output follow the existing CLI conventions. + ## [0.18.1] ### Fixed diff --git a/Cargo.toml b/Cargo.toml index dce8c53..170ae4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ scraper = "0.27.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.151" thiserror = "2.0.20" -tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "time"] } toml = "1.1.4" tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } diff --git a/README.md b/README.md index 250d07e..77e4ae7 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,26 @@ how to get it: export KAGI_API_KEY='...' ``` +### mail + +Configure your mail MCP endpoint and OAuth client ID in a private `[mail]` +section, then sign in once: + +```bash +kagi mail login +kagi mail boxes +kagi mail search --mailbox Inbox --unread --limit 10 +kagi mail search "contract renewal" --semantic +kagi mail read MESSAGE_ID --format pretty +kagi mail read --thread THREAD_ID --new-text-only +``` + +Mail uses its own OAuth credentials. It supports `--profile`, JSON by default, +and `--format compact|toon|pretty`. The MCP exposes read operations only; +messages are not added to the local history or response cache. +See the [mail command reference](docs/content/docs/commands/mail.mdx) for setup, +filters, and token handling. + ## auth model | credential | what it unlocks | @@ -142,7 +162,8 @@ export KAGI_API_KEY='...' | `KAGI_SESSION_TOKEN` | base search fallback, session-only search options, `quick`, `ask-page`, `assistant`, `translate`, and `summarize --subscriber` | | `KAGI_API_KEY` | current `/api/v1` Search API and Extract API with `Bearer` auth | | `KAGI_API_TOKEN` | legacy `/api/v0` public `summarize`, `fastgpt`, `enrich web`, and `enrich news` with `Bot` auth | -| none | `news`, `smallweb`, `auth status`, `--help` | +| saved mail OAuth tokens or `KAGI_MAIL_ACCESS_TOKEN` | `mail boxes`, `mail search`, and `mail read` | +| none | `news`, `smallweb`, `auth status`, `mail status`, `--help` | example config: @@ -190,6 +211,7 @@ for the full command-to-token matrix, use the [`auth-matrix`](https://kagi.micr. | `kagi skills` | list and load embedded, version-matched agent skills with `skills get kagi-usage` as the agent starting point | | `kagi batch` | run multiple searches in parallel with JSON, TOON, compact, pretty, markdown, or csv output and shared filters | | `kagi auth` | launch the auth wizard, or inspect, validate, and save credentials | +| `kagi mail` | list mailboxes, search mail, and read messages or threads with separate OAuth login | | `kagi completion` | generate or install shell completions for bash, zsh, fish, or PowerShell | | `kagi summarize` | use the paid public summarizer API or the subscriber summarizer with `--subscriber` | | `kagi extract` | extract a page's full content as markdown through the current paid API, using `KAGI_API_KEY` directly | diff --git a/docs/content/docs/commands/mail.mdx b/docs/content/docs/commands/mail.mdx new file mode 100644 index 0000000..3bd6639 --- /dev/null +++ b/docs/content/docs/commands/mail.mdx @@ -0,0 +1,88 @@ +--- +title: mail +description: Search and read Kagi Mail from the terminal. +--- + +`kagi mail` lists mailboxes, searches messages, and reads messages or threads +through the mail MCP. These operations do not send, delete, move, or mark mail +as read. Responses are not stored in local history or the response cache. + +## Setup + +Add the MCP endpoint and OAuth client ID supplied by your mail service to your +private `~/.config/kagi-cli/config.toml`: + +```toml +[mail] +endpoint = "https://mail.example.com/mcp" +client_id = "your-oauth-client-id" +``` + +```bash +kagi mail login +kagi mail status +``` + +Login prints a verification URL and code to stderr. Open the URL in a browser +and approve access. The command waits up to the provider's expiry, capped at +10 minutes, then saves OAuth tokens in the same private config file. It refreshes +expired tokens automatically and binds refresh credentials to their OAuth issuer. +If the provider omits expiry, a saved refresh token renews access before use. +Ctrl-C cancels login. + +`KAGI_MAIL_ENDPOINT` and `KAGI_MAIL_CLIENT_ID` override config. For automation, +`KAGI_MAIL_ACCESS_TOKEN` supplies an existing OAuth bearer token; this override +is never saved or refreshed. Search API keys and session tokens do not grant +mail access. Never put credentials on the command line. + +`--profile work` selects `[profiles.work.mail]`, without inheriting another +profile's mailbox or tokens. `KAGI_CONFIG` and XDG config paths work as usual. +`kagi mail logout` removes saved mail tokens only; it does not revoke access or +clear environment variables. Status reports configuration and expiry without +printing private settings or making network requests. + +## Commands + +```bash +kagi mail boxes +kagi mail search --mailbox Inbox --unread --limit 10 +kagi mail search "invoice" --from billing@example.com +kagi mail search "the contract renewal" --semantic +kagi mail search --after 2026-01-01 --before 2026-02-01 --has-attachment +kagi mail read MESSAGE_ID --format pretty +kagi mail read --thread THREAD_ID --new-text-only +kagi mail search "invoice" --format compact | jq '.emails[]?.id' +``` + +`boxes` returns mailbox paths and total/unread counts. Pass the full returned +path to `--mailbox`. + +`search [QUERY]` matches literal text by default. Omit QUERY to list recent mail +or filter by `--from`, `--to`, `--cc`, `--subject`, `--mailbox`, `--after`, +`--before`, `--unread`, `--has-attachment`, and `--min-size`. Dates accept +YYYY-MM-DD or RFC 3339. `--after` is inclusive and `--before` is exclusive. +`--limit` defaults to 20 and accepts 1 through 50. The MCP has no pagination. +`--semantic` requires QUERY and searches by meaning with the same filters. + +`read` takes exactly one message ID or `--thread THREAD_ID`. Threads arrive +oldest first. `--new-text-only` drops quoted reply history. Attachment metadata +is included when available; attachment download is not exposed by the MCP. + +## Output and errors + +All commands default to `--format json`. Use `compact` for minified JSON, +`toon` for agent context, or `pretty` for plain terminal text. The format flag +works before or after the mail subcommand. JSON/TOON retain the server's tool +result, including IDs, URLs, notes, and truncation flags, without the MCP wrapper. +Empty `emails` or `mailboxes` arrays may be `null`, as returned by the service. +Pretty output shows message IDs and thread IDs for the next command. + +Success exits 0, argument parsing errors exit 2, and runtime failures exit 1. Runtime +errors go to stderr and support the global `--error-format json`. Authentication +failures suggest `kagi mail status` and `kagi mail login`. Temporary token-service +failures during login or refresh, including HTTP 429 and server errors, remain +retryable and leave saved tokens intact. Error diagnostics do not echo remote +response bodies, credentials, or private endpoints. Login removes control +characters from the displayed code. +Rejected OAuth clients report a configuration error identifying the client ID setting. +An invalid grant requires another `kagi mail login`. diff --git a/docs/content/docs/commands/meta.json b/docs/content/docs/commands/meta.json index 758eb3c..d293594 100644 --- a/docs/content/docs/commands/meta.json +++ b/docs/content/docs/commands/meta.json @@ -18,6 +18,7 @@ "---Content & Feeds", "news", "smallweb", + "mail", "---AI & Enrichment", "assistant", "quick", diff --git a/docs/content/docs/reference/auth-matrix.mdx b/docs/content/docs/reference/auth-matrix.mdx index 3070b5b..66b39ab 100644 --- a/docs/content/docs/reference/auth-matrix.mdx +++ b/docs/content/docs/reference/auth-matrix.mdx @@ -18,6 +18,8 @@ This reference provides a complete mapping of which commands require which authe | `auth status` | None | None | Reads config only | | `auth check` | Primary credential | None | Tests selected token | | `auth set` | None | None | Saves credentials | +| `mail boxes/search/read` | Mail OAuth token | None | `kagi mail login` or `KAGI_MAIL_ACCESS_TOKEN`; separate from Search auth | +| `mail login/status/logout` | Private mail configuration | None | Device login, local status, and local token removal | | `usage` / `billing` | `KAGI_SESSION_TOKEN` | None | Reads account billing and calendar-month usage | | `summarize` | `KAGI_API_TOKEN` | None | Legacy `/api/v0` public API | | `summarize --subscriber` | `KAGI_SESSION_TOKEN` | None | Subscriber web product | @@ -345,7 +347,7 @@ kagi auth # then Legacy API Token; each paste is saved immediately ``` -**All commands work:** +**All search, AI, and feed commands work. Mail requires separate OAuth login:** - ✅ Everything listed above **Smart behavior:** diff --git a/skills/kagi-usage/SKILL.md b/skills/kagi-usage/SKILL.md index 3fdbd8a..2e1c679 100644 --- a/skills/kagi-usage/SKILL.md +++ b/skills/kagi-usage/SKILL.md @@ -19,6 +19,7 @@ skill named below. | Continue a conversation or manage Assistant state | `kagi-assistant` | | Run searches in bulk or monitor changes over time | `kagi-monitoring` | | Configure credentials or account search behavior | `kagi-account-config` | +| Search or read mail | `kagi mail` (below) | Load the skill when it appears in the available skills listing. Otherwise run `kagi skills get ` for the same guidance. @@ -203,3 +204,22 @@ Page work is complete when: - quotes remain distinguishable from summaries; - the output keeps the requested language and format; and - you report a source-access failure instead of guessing past it. + +## Mail + +Mail uses separate OAuth credentials and private connection settings. Run +`kagi mail login` after configuring `[mail].endpoint` and `[mail].client_id`. +Search API keys and session tokens do not grant mail access. + +```bash +kagi mail boxes +kagi mail search --mailbox Inbox --unread --limit 10 +kagi mail search "contract renewal" --semantic --format toon +kagi mail read MESSAGE_ID --format pretty +kagi mail read --thread THREAD_ID --new-text-only +``` + +Use exact search for known words, senders, subjects, or dates. Add `--semantic` +for a topic described in natural language. Use returned IDs to read messages. +The MCP exposes no send, delete, mark-read, or attachment-download operation. +Mail content stays out of the CLI's local history and response cache. diff --git a/src/auth.rs b/src/auth.rs index 99031e4..e0dffa7 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -7,6 +7,7 @@ use std::collections::BTreeMap; use std::env; use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; use reqwest::Url; @@ -241,6 +242,8 @@ impl CredentialInventory { #[derive(Debug, Default, Deserialize, serde::Serialize)] struct ConfigFile { auth: Option, + #[serde(skip_serializing_if = "Option::is_none")] + mail: Option, profiles: Option>, } @@ -255,6 +258,52 @@ struct AuthConfig { #[derive(Debug, Default, Deserialize, serde::Serialize)] struct ProfileConfig { auth: Option, + #[serde(skip_serializing_if = "Option::is_none")] + mail: Option, +} + +/// Mail has its own OAuth credentials, isolated from search auth and other profiles. +pub fn load_mail_config(profile: Option<&str>) -> Result { + // TOML parser diagnostics can quote the source line, which may contain a token. + let config = read_config_file(&default_config_path()).map_err(|_| { + KagiError::Config("could not read mail configuration; check KAGI_CONFIG and the config file's TOML syntax".into()) + })?; + let profile = normalize_profile_name(profile)?; + let mail = match profile.as_deref() { + None => config.mail, + Some(name) => config + .profiles + .and_then(|mut profiles| profiles.remove(name)) + .and_then(|profile| profile.mail), + }; + Ok(mail.unwrap_or_default()) +} + +/// Preserve search auth and every other profile when saving mail settings or tokens. +pub fn save_mail_config( + profile: Option<&str>, + mail: crate::mail_auth::MailConfig, +) -> Result<(), KagiError> { + let path = default_config_path(); + let mut config = read_config_file(&path).map_err(|_| { + KagiError::Config( + "could not read mail configuration; check the config file's TOML syntax".into(), + ) + })?; + match normalize_profile_name(profile)? { + None => config.mail = Some(mail), + Some(name) => { + config + .profiles + .get_or_insert_with(BTreeMap::new) + .entry(name) + .or_default() + .mail = Some(mail) + } + } + let raw = toml::to_string(&config) + .map_err(|_| KagiError::Config("could not serialize mail configuration".into()))?; + write_config_file_atomically(&path, &raw) } #[derive(Debug, Clone)] @@ -791,13 +840,27 @@ fn write_config_file_atomically(path: &Path, raw: &str) -> Result<(), KagiError> .unwrap_or_default(); let temp_path = parent.join(format!(".{file_name}.tmp-{}-{nonce}", std::process::id())); - fs::write(&temp_path, raw).map_err(|error| { - KagiError::Config(format!( - "failed to write temporary config file {}: {error}", - temp_path.display() - )) + // Create the file privately before writing OAuth or search credentials. + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&temp_path).map_err(|error| { + KagiError::Config(format!("failed to create temporary config file: {error}")) })?; - secure_config_permissions(&temp_path)?; + if let Err(error) = file + .write_all(raw.as_bytes()) + .and_then(|()| file.sync_all()) + { + let _ = fs::remove_file(&temp_path); + return Err(KagiError::Config(format!( + "failed to write temporary config file: {error}" + ))); + } + drop(file); if let Err(error) = fs::rename(&temp_path, path) { let _ = fs::remove_file(&temp_path); diff --git a/src/cli.rs b/src/cli.rs index 2aab176..7471381 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -323,6 +323,8 @@ pub enum Commands { /// Inspect account plan, AI allowance, renewal, and calendar-month usage #[command(visible_alias = "billing")] Usage(UsageArgs), + /// Search and read Kagi Mail + Mail(crate::mail::MailCommand), /// Summarize a URL or text with Kagi's public API or subscriber web Summarizer Summarize(SummarizeArgs), /// Extract a page's full content as markdown through Kagi's Extract API diff --git a/src/error.rs b/src/error.rs index b05eb25..f6e88e2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -21,6 +21,10 @@ pub enum KagiError { #[error("authentication error: {0}")] Auth(String), + /// Mail authorization requires separate OAuth credentials and recovery commands. + #[error("authentication error: {0}")] + MailAuth(String), + /// A data parsing or deserialization failure. #[error("parse error: {0}")] Parse(String), diff --git a/src/mail-auth.rs b/src/mail-auth.rs new file mode 100644 index 0000000..1792bfc --- /dev/null +++ b/src/mail-auth.rs @@ -0,0 +1,502 @@ +//! OAuth device login and token storage for the mail service. +//! +//! Connection details belong to private configuration. Search credentials are +//! never sent to the mail service, and transport errors never print its URLs. + +use std::env; +use std::fs::{self, File, OpenOptions}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use fs2::FileExt; +use reqwest::{Client, Response, Url}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::{auth, error::KagiError}; + +#[derive(Clone, Default, Deserialize, Serialize)] +pub struct MailConfig { + pub endpoint: Option, + pub client_id: Option, + pub issuer: Option, + pub access_token: Option, + pub refresh_token: Option, + pub expires_at: Option, +} + +impl std::fmt::Debug for MailConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("MailConfig { }") + } +} + +impl MailConfig { + pub fn load(profile: Option<&str>) -> Result { + let mut config = auth::load_mail_config(profile)?; + // An endpoint/client override selects a different connection. Do not + // carry a saved account's tokens over to that connection. + for (field, key) in [ + (&mut config.endpoint, "KAGI_MAIL_ENDPOINT"), + (&mut config.client_id, "KAGI_MAIL_CLIENT_ID"), + ] { + if let Some(value) = env_value(key) { + if field.as_ref() != Some(&value) { + config.access_token = None; + config.refresh_token = None; + config.expires_at = None; + } + *field = Some(value); + } + } + if let Some(token) = env_value("KAGI_MAIL_ACCESS_TOKEN") { + config.access_token = Some(token); + config.refresh_token = None; + config.expires_at = None; + } + Ok(config) + } + + pub fn endpoint(&self) -> Result { + let endpoint = self.endpoint.as_deref().filter(|s| !s.trim().is_empty()) + .ok_or_else(|| KagiError::Config("mail requires KAGI_MAIL_ENDPOINT or [mail].endpoint in the selected profile's config".into()))?; + private_url(endpoint) + } + + fn client_id(&self) -> Result<&str, KagiError> { + self.client_id.as_deref().filter(|s| !s.trim().is_empty()) + .ok_or_else(|| KagiError::Config("mail login requires KAGI_MAIL_CLIENT_ID or [mail].client_id in the selected profile's config".into())) + } + + fn needs_refresh(&self) -> bool { + self.expires_at + .map_or(self.refresh_token.is_some(), |expiry| { + expiry <= now().saturating_add(30) + }) + } + + pub fn status(&self) -> Value { + json!({ + "endpoint_configured": self.endpoint.is_some(), + "client_id_configured": self.client_id.is_some(), + "access_token_configured": self.access_token.is_some(), + "refresh_token_configured": self.refresh_token.is_some(), + "expired": self.expires_at.map(|expiry| expiry <= now()), + "token_source": if env_value("KAGI_MAIL_ACCESS_TOKEN").is_some() { "env" } else { "config" }, + }) + } +} + +fn env_value(key: &str) -> Option { + env::var(key) + .ok() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) +} + +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +pub fn client() -> Result { + Client::builder() + .user_agent("OpenAI File Downloader, XaiImageApiFetch/1.0") + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(60)) + .build() + .map_err(transport_error) +} + +/// HTTP is allowed only on loopback, for local services and contract tests. +/// Reject credentials in URLs, and never include the rejected value in errors. +pub fn private_url(value: &str) -> Result { + let url = Url::parse(value).map_err(|_| { + KagiError::Config("mail service URL is invalid; use an absolute HTTPS URL".into()) + })?; + let loopback = matches!(url.host_str(), Some("127.0.0.1" | "[::1]" | "localhost")); + if (url.scheme() != "https" && !(url.scheme() == "http" && loopback)) + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err(KagiError::Config("mail service URLs require HTTPS without embedded credentials or fragments (HTTP is allowed on loopback)".into())); + } + Ok(url) +} + +pub fn transport_error(error: reqwest::Error) -> KagiError { + let message = if error.is_timeout() { + "mail request timed out; retry or check your connection" + } else { + "mail request failed; check your connection and private mail configuration" + }; + KagiError::Network(message.into()) +} + +pub fn login_required() -> KagiError { + KagiError::MailAuth("mail authorization is missing or expired; run `kagi mail login` (include --profile when using a named profile), or set KAGI_MAIL_ACCESS_TOKEN".into()) +} + +pub fn check_status(response: &Response) -> Result<(), KagiError> { + match response.status().as_u16() { + 200..=299 => Ok(()), + 401 | 403 => Err(login_required()), + status => Err(KagiError::Network(format!( + "mail service returned HTTP {status}; retry or check your mail configuration" + ))), + } +} + +#[derive(Deserialize)] +struct ResourceMetadata { + resource: String, + authorization_servers: Vec, + #[serde(default)] + scopes_supported: Vec, +} + +#[derive(Deserialize)] +struct OAuthMetadata { + issuer: String, + token_endpoint: String, + device_authorization_endpoint: Option, +} + +struct Discovery { + oauth: OAuthMetadata, + resource: String, + scope: String, +} + +async fn json_response(response: Response) -> Result { + response + .json() + .await + .map_err(|_| KagiError::Parse("mail service returned invalid JSON".into())) +} + +/// OAuth endpoints use HTTP 400/401 for protocol errors. Preserve grant-specific +/// responses, but classify client configuration and temporary failures first. +async fn oauth_response(response: Response) -> Result<(reqwest::StatusCode, Value), KagiError> { + let status = response.status(); + if !matches!(status.as_u16(), 400 | 401) { + check_status(&response)?; + } + let value = json_response(response).await?; + match value.get("error").and_then(Value::as_str) { + Some("invalid_grant") => return Err(login_required()), + Some("temporarily_unavailable") => { + return Err(KagiError::Network(format!( + "mail authorization service is temporarily unavailable (HTTP {}); retry later", + status.as_u16() + ))); + } + Some("invalid_client" | "unauthorized_client") => { + return Err(KagiError::Config(format!( + "mail OAuth client was rejected (HTTP {}); check KAGI_MAIL_CLIENT_ID or [mail].client_id", + status.as_u16() + ))); + } + _ => {} + } + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(login_required()); + } + Ok((status, value)) +} + +async fn metadata(client: &Client, url: Url) -> Result { + client.get(url).send().await.map_err(transport_error) +} + +async fn discover(client: &Client, endpoint: Url) -> Result { + let response = client + .get(endpoint.clone()) + .header("Accept", "application/json, text/event-stream") + .send() + .await + .map_err(transport_error)?; + let challenge = response + .headers() + .get(reqwest::header::WWW_AUTHENTICATE) + .and_then(|header| header.to_str().ok()) + .unwrap_or_default(); + let metadata_url = challenge + .split("resource_metadata=\"") + .nth(1) + .and_then(|s| s.split('"').next()) + .ok_or_else(|| { + KagiError::MailAuth( + "mail service did not advertise OAuth resource metadata; check [mail].endpoint" + .into(), + ) + })?; + let response = metadata(client, private_url(metadata_url)?).await?; + check_status(&response)?; + let resource: ResourceMetadata = serde_json::from_value(json_response(response).await?) + .map_err(|_| KagiError::Parse("mail OAuth resource metadata is incomplete".into()))?; + if private_url(&resource.resource)? != endpoint { + return Err(KagiError::MailAuth( + "mail OAuth metadata identifies a different resource; check [mail].endpoint".into(), + )); + } + let issuer = resource.authorization_servers.first().ok_or_else(|| { + KagiError::MailAuth("mail service did not advertise an OAuth issuer".into()) + })?; + let issuer_url = private_url(issuer)?; + let issuer_path = issuer_url.path().trim_end_matches('/'); + let mut discovery_url = issuer_url.clone(); + discovery_url.set_path(&format!( + "/.well-known/oauth-authorization-server{issuer_path}" + )); + discovery_url.set_query(None); + let mut response = metadata(client, discovery_url).await?; + // OAuth issuers may publish their endpoints through OpenID Connect + // discovery instead of RFC 8414. Both are standard discovery mechanisms. + if response.status() == reqwest::StatusCode::NOT_FOUND { + let mut oidc_url = issuer_url.clone(); + oidc_url.set_path(&format!("{issuer_path}/.well-known/openid-configuration")); + oidc_url.set_query(None); + response = metadata(client, oidc_url).await?; + } + check_status(&response)?; + let oauth: OAuthMetadata = serde_json::from_value(json_response(response).await?) + .map_err(|_| KagiError::Parse("mail OAuth issuer metadata is incomplete".into()))?; + if private_url(&oauth.issuer)? != issuer_url { + return Err(KagiError::MailAuth( + "mail OAuth discovery returned a different issuer".into(), + )); + } + private_url(&oauth.token_endpoint)?; + let mut scopes = resource.scopes_supported; + if !scopes.iter().any(|scope| scope == "offline_access") { + scopes.push("offline_access".into()); + } + Ok(Discovery { + oauth, + resource: resource.resource, + scope: scopes.join(" "), + }) +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + token_type: String, + refresh_token: Option, + expires_in: Option, +} + +fn apply_token(config: &mut MailConfig, value: Value) -> Result<(), KagiError> { + let token: TokenResponse = serde_json::from_value(value) + .map_err(|_| KagiError::Parse("mail OAuth response is missing a bearer token".into()))?; + if !token.token_type.eq_ignore_ascii_case("bearer") || token.access_token.trim().is_empty() { + return Err(KagiError::MailAuth( + "mail OAuth response did not provide a valid bearer token".into(), + )); + } + config.access_token = Some(token.access_token); + if let Some(refresh) = token.refresh_token { + config.refresh_token = Some(refresh); + } + config.expires_at = token + .expires_in + .map(|seconds| now().saturating_add(seconds)); + Ok(()) +} + +/// Serialize token rotation across CLI processes. The file contains no secrets; +/// dropping it releases the OS lock even after cancellation or a failed request. +fn token_lock() -> Result { + let lock_path = auth::default_config_path().with_extension("mail.lock"); + if let Some(parent) = lock_path.parent().filter(|p| !p.as_os_str().is_empty()) { + fs::create_dir_all(parent) + .map_err(|_| KagiError::Config("could not create the mail config directory".into()))?; + } + let file = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(lock_path) + .map_err(|_| KagiError::Config("could not open the mail token lock".into()))?; + file.lock_exclusive() + .map_err(|_| KagiError::Config("could not lock mail tokens".into()))?; + Ok(file) +} + +pub async fn access_token( + client: &Client, + profile: Option<&str>, +) -> Result<(Url, String), KagiError> { + let mut config = MailConfig::load(profile)?; + let mut endpoint = config.endpoint()?; + if config.needs_refresh() { + let _lock = token_lock()?; + // Another process may have refreshed while this process waited. + config = MailConfig::load(profile)?; + endpoint = config.endpoint()?; + if config.needs_refresh() { + let refresh = config.refresh_token.as_deref().ok_or_else(login_required)?; + let discovery = discover(client, endpoint.clone()).await?; + // Bind saved refresh credentials to the issuer that granted them. + // Resource metadata alone cannot authorize a different issuer. + if config.issuer.as_deref() != Some(discovery.oauth.issuer.as_str()) { + return Err(KagiError::MailAuth("mail OAuth issuer changed or is missing; run `kagi mail login` before refreshing credentials".into())); + } + let response = client + .post(private_url(&discovery.oauth.token_endpoint)?) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", refresh), + ("client_id", config.client_id()?), + ("resource", &discovery.resource), + ]) + .send() + .await + .map_err(transport_error)?; + let (status, value) = oauth_response(response).await?; + if status == reqwest::StatusCode::BAD_REQUEST { + return Err(KagiError::Config( + "mail token refresh failed (HTTP 400); check your OAuth client configuration" + .into(), + )); + } + apply_token(&mut config, value)?; + auth::save_mail_config(profile, config.clone())?; + } + } + let token = config + .access_token + .filter(|s| !s.trim().is_empty()) + .ok_or_else(login_required)?; + Ok((endpoint, token)) +} + +#[derive(Deserialize)] +struct DeviceAuthorization { + device_code: String, + user_code: String, + verification_uri: String, + verification_uri_complete: Option, + expires_in: u64, + #[serde(default = "default_poll_interval")] + interval: u64, +} + +fn default_poll_interval() -> u64 { + 5 +} + +pub async fn login(profile: Option<&str>) -> Result { + let client = client()?; + let mut config = MailConfig::load(profile)?; + if env_value("KAGI_MAIL_ACCESS_TOKEN").is_some() { + return Err(KagiError::Config("unset KAGI_MAIL_ACCESS_TOKEN before running `kagi mail login`; it overrides saved tokens".into())); + } + let client_id = config.client_id()?.to_owned(); + let discovery = discover(&client, config.endpoint()?).await?; + let device_endpoint = discovery.oauth.device_authorization_endpoint + .ok_or_else(|| KagiError::MailAuth("mail OAuth issuer does not support device login; supply KAGI_MAIL_ACCESS_TOKEN from an authorized OAuth client".into()))?; + let response = client + .post(private_url(&device_endpoint)?) + .form(&[ + ("client_id", client_id.as_str()), + ("scope", &discovery.scope), + ("resource", &discovery.resource), + ]) + .send() + .await + .map_err(transport_error)?; + let (status, value) = oauth_response(response).await?; + if !status.is_success() { + return Err(KagiError::Config(format!( + "mail device login request was rejected (HTTP {}); check your OAuth client configuration", + status.as_u16() + ))); + } + let device: DeviceAuthorization = serde_json::from_value(value) + .map_err(|_| KagiError::Parse("mail OAuth device response is incomplete".into()))?; + let verification_url = private_url( + device + .verification_uri_complete + .as_deref() + .unwrap_or(&device.verification_uri), + )?; + eprintln!( + "Open {verification_url}\nCode: {}\nWaiting for approval. Press Ctrl-C to cancel.", + device + .user_code + .chars() + .filter(|c| !c.is_control()) + .collect::() + ); + let deadline = tokio::time::Instant::now() + Duration::from_secs(device.expires_in.min(600)); + let mut interval = device.interval.max(1); + let token_url = private_url(&discovery.oauth.token_endpoint)?; + loop { + let poll = async { + tokio::time::sleep(Duration::from_secs(interval)).await; + client + .post(token_url.clone()) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("device_code", device.device_code.as_str()), + ("client_id", client_id.as_str()), + ]) + .send() + .await + .map_err(transport_error) + }; + let response = tokio::time::timeout_at(deadline, poll) + .await + .map_err(|_| { + KagiError::MailAuth("mail login timed out; run `kagi mail login` again".into()) + })??; + let (status, value) = oauth_response(response).await?; + if status.is_success() { + // A new login must not retain a previous account's refresh token. + config.refresh_token = None; + config.issuer = Some(discovery.oauth.issuer.clone()); + apply_token(&mut config, value)?; + let _lock = token_lock()?; + auth::save_mail_config(profile, config)?; + return Ok(json!({"authenticated": true})); + } + match value.get("error").and_then(Value::as_str) { + Some("authorization_pending") => {} + Some("slow_down") => interval = interval.saturating_add(5), + Some("access_denied") => { + return Err(KagiError::MailAuth( + "mail login was denied; run `kagi mail login` to try again".into(), + )); + } + Some("expired_token") => { + return Err(KagiError::MailAuth( + "mail login code expired; run `kagi mail login` again".into(), + )); + } + _ => { + return Err(KagiError::MailAuth(format!( + "mail login failed (HTTP {}); check your OAuth client configuration", + status.as_u16() + ))); + } + } + } +} + +pub fn logout(profile: Option<&str>) -> Result { + let _lock = token_lock()?; + let mut config = auth::load_mail_config(profile)?; + config.access_token = None; + config.refresh_token = None; + config.expires_at = None; + auth::save_mail_config(profile, config)?; + Ok( + json!({"saved_tokens_removed": true, "environment_token_configured": env_value("KAGI_MAIL_ACCESS_TOKEN").is_some()}), + ) +} diff --git a/src/mail.rs b/src/mail.rs new file mode 100644 index 0000000..9555400 --- /dev/null +++ b/src/mail.rs @@ -0,0 +1,489 @@ +//! Mail commands and the small Streamable HTTP MCP client they share. + +use clap::{Args, Subcommand, ValueEnum}; +use reqwest::{Client, Response, Url, header::HeaderValue}; +use serde_json::{Value, json}; + +use crate::{error::KagiError, mail_auth}; + +#[derive(Debug, Args)] +#[command( + after_help = "Examples:\n kagi mail login\n kagi mail boxes\n kagi mail search --mailbox Inbox --unread\n kagi mail search \"contract renewal\" --semantic\n kagi mail read MESSAGE_ID --format pretty" +)] +pub struct MailCommand { + #[command(subcommand)] + pub command: MailSubcommand, + /// Output format + #[arg(long, global = true, value_enum, default_value = "json")] + pub format: MailFormat, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum MailFormat { + Json, + Compact, + Toon, + Pretty, +} + +#[derive(Debug, Subcommand)] +pub enum MailSubcommand { + /// Sign in using a browser verification code and save OAuth tokens + Login, + /// Show mail credential configuration without contacting the service + Status, + /// Remove saved mail tokens (does not revoke access or clear environment variables) + Logout, + /// List mailbox paths and total/unread message counts + #[command(visible_alias = "mailboxes")] + Boxes, + /// Search literal text and filters, or use --semantic to search by meaning + Search(MailSearchArgs), + /// Read one message or every message in a thread + Read(MailReadArgs), +} + +#[derive(Debug, Args)] +pub struct MailSearchArgs { + /// Text to match; omit to list recent mail using the selected filters + #[arg(value_name = "QUERY")] + pub query: Option, + /// Search by meaning instead of literal text (requires QUERY) + #[arg(long, requires = "query")] + pub semantic: bool, + /// Match sender address or display name + #[arg(long)] + pub from: Option, + /// Match recipient address or display name + #[arg(long)] + pub to: Option, + /// Match a cc recipient + #[arg(long)] + pub cc: Option, + /// Match words in the subject + #[arg(long)] + pub subject: Option, + /// Full mailbox path from `kagi mail boxes` + #[arg(long)] + pub mailbox: Option, + /// Received at or after this date (YYYY-MM-DD or RFC 3339) + #[arg(long, value_name = "DATE")] + pub after: Option, + /// Received before this date (YYYY-MM-DD or RFC 3339) + #[arg(long, value_name = "DATE")] + pub before: Option, + /// Only unread messages + #[arg(long)] + pub unread: bool, + /// Only messages with attachments + #[arg(long)] + pub has_attachment: bool, + /// Only messages at least this many bytes in size + #[arg(long, value_name = "BYTES")] + pub min_size: Option, + /// Maximum messages to return (1-50; the service has no pagination) + #[arg(long, default_value_t = 20, value_parser = clap::value_parser!(u8).range(1..=50))] + pub limit: u8, +} + +impl MailSearchArgs { + fn tool_call(self) -> Result<(&'static str, Value), KagiError> { + if self + .query + .as_ref() + .is_some_and(|query| query.trim().is_empty()) + { + return Err(KagiError::Config( + "mail search query is empty; omit it to list recent mail, or enter search text" + .into(), + )); + } + let mut arguments = serde_json::Map::new(); + for (key, value) in [ + (if self.semantic { "query" } else { "text" }, self.query), + ("from", self.from), + ("to", self.to), + ("cc", self.cc), + ("subject", self.subject), + ("mailbox", self.mailbox), + ("after", self.after), + ("before", self.before), + ] { + if let Some(value) = value { + arguments.insert(key.into(), value.into()); + } + } + arguments.insert("limit".into(), self.limit.into()); + if self.unread { + arguments.insert("unreadOnly".into(), true.into()); + } + if self.has_attachment { + arguments.insert("hasAttachment".into(), true.into()); + } + if let Some(size) = self.min_size { + arguments.insert("minSizeBytes".into(), size.into()); + } + Ok(( + if self.semantic { + "semantic_search" + } else { + "search_email" + }, + arguments.into(), + )) + } +} + +#[derive(Debug, Args)] +pub struct MailReadArgs { + /// Message ID returned by a search + #[arg( + value_name = "MESSAGE_ID", + required_unless_present = "thread", + conflicts_with = "thread" + )] + pub message_id: Option, + /// Read all messages in this thread, oldest first + #[arg(long, value_name = "THREAD_ID")] + pub thread: Option, + /// Drop quoted reply history and keep only newly written text + #[arg(long)] + pub new_text_only: bool, +} + +pub async fn run(args: MailCommand, profile: Option<&str>) -> Result<(), KagiError> { + let value = match args.command { + MailSubcommand::Login => mail_auth::login(profile).await?, + MailSubcommand::Status => mail_auth::MailConfig::load(profile)?.status(), + MailSubcommand::Logout => mail_auth::logout(profile)?, + command => { + let (tool, arguments) = match command { + MailSubcommand::Boxes => ("list_mailboxes", json!({})), + MailSubcommand::Search(search) => search.tool_call()?, + MailSubcommand::Read(read) => { + let (key, id) = match (read.message_id, read.thread) { + (Some(id), None) => ("emailId", id), + (None, Some(id)) => ("threadId", id), + _ => unreachable!("clap requires exactly one message or thread ID"), + }; + if id.trim().is_empty() { + return Err(KagiError::Config("mail read requires a nonempty message or thread ID from a search result".into())); + } + ( + "get_email", + json!({key: id, "newTextOnly": read.new_text_only}), + ) + } + _ => unreachable!("local mail commands were handled above"), + }; + let client = mail_auth::client()?; + let (endpoint, token) = mail_auth::access_token(&client, profile).await?; + let mut rpc = MailRpc { + client, + endpoint, + token, + session: None, + protocol: None, + }; + let response = rpc.call(tool, arguments).await; + rpc.close().await; + response? + } + }; + match args.format { + MailFormat::Json => crate::print_json(&value), + MailFormat::Compact => crate::print_compact_json(&value), + MailFormat::Toon => crate::print_toon(&value), + MailFormat::Pretty => { + println!("{}", format_pretty(&value)); + Ok(()) + } + } +} + +struct MailRpc { + client: Client, + endpoint: Url, + token: String, + session: Option, + protocol: Option, +} + +impl MailRpc { + fn request(&self, method: reqwest::Method) -> reqwest::RequestBuilder { + let mut request = self + .client + .request(method, self.endpoint.clone()) + .bearer_auth(&self.token) + .header("Accept", "application/json, text/event-stream"); + if let Some(session) = &self.session { + request = request.header("Mcp-Session-Id", session); + } + if let Some(protocol) = &self.protocol { + request = request.header("MCP-Protocol-Version", protocol); + } + request + } + + async fn post(&mut self, body: Value) -> Result { + let response = self + .request(reqwest::Method::POST) + .json(&body) + .send() + .await + .map_err(mail_auth::transport_error)?; + mail_auth::check_status(&response)?; + if self.session.is_none() { + self.session = response + .headers() + .get("Mcp-Session-Id") + .cloned() + .map(|mut value| { + value.set_sensitive(true); + value + }); + } + Ok(response) + } + + async fn call(&mut self, tool: &str, arguments: Value) -> Result { + let response = self + .post(json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-11-25", "capabilities": {}, + "clientInfo": {"name": "kagi-cli", "version": env!("CARGO_PKG_VERSION")}} + })) + .await?; + let initialized = rpc_response(response, 1).await?; + let protocol = initialized + .get("protocolVersion") + .and_then(Value::as_str) + .filter(|version| matches!(*version, "2025-03-26" | "2025-06-18" | "2025-11-25")) + .ok_or_else(|| { + KagiError::Parse("mail service selected an unsupported MCP protocol version".into()) + })?; + self.protocol = Some(protocol.into()); + self.post(json!({"jsonrpc": "2.0", "method": "notifications/initialized"})) + .await?; + let response = self + .post(json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": {"name": tool, "arguments": arguments}})) + .await?; + tool_result(rpc_response(response, 2).await?, tool) + } + + async fn close(&self) { + if self.session.is_some() { + // End only this MCP session, never mailbox state. A server may not + // implement DELETE; cleanup must not discard a successful read. + let _ = self + .request(reqwest::Method::DELETE) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await; + } + } +} + +fn parse_error(message: &str) -> KagiError { + KagiError::Parse(message.into()) +} + +fn rpc_result(mut value: Value, id: u64) -> Result { + if value.get("jsonrpc").and_then(Value::as_str) != Some("2.0") + || value.get("id") != Some(&json!(id)) + { + return Err(parse_error( + "mail service returned an unexpected MCP response", + )); + } + if let Some(error) = value.get("error") { + let code = error + .get("code") + .and_then(Value::as_i64) + .unwrap_or_default(); + return Err(parse_error(&format!( + "mail MCP request failed (code {code}); check the service configuration and supported tools" + ))); + } + value + .get_mut("result") + .map(Value::take) + .ok_or_else(|| parse_error("mail MCP response has no result")) +} + +/// Read JSON or SSE until the matching response arrives. SSE connections may +/// remain open after a result, so waiting for the entire HTTP body would hang. +async fn rpc_response(mut response: Response, id: u64) -> Result { + let sse = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|h| h.to_str().ok()) + .is_some_and(|s| s.split(';').next() == Some("text/event-stream")); + let mut buffer = Vec::new(); + let mut event = String::new(); + let mut received = 0usize; + while let Some(chunk) = response.chunk().await.map_err(mail_auth::transport_error)? { + received += chunk.len(); + if received > 16 * 1024 * 1024 { + return Err(parse_error("mail MCP response exceeded 16 MiB")); + } + buffer.extend_from_slice(&chunk); + if !sse { + continue; + } + let mut consumed = 0; + for line in buffer.split_inclusive(|byte| *byte == b'\n') { + if !line.ends_with(b"\n") { + break; + } + consumed += line.len(); + let line = std::str::from_utf8(line) + .map_err(|_| parse_error("mail MCP stream is not UTF-8"))? + .trim_end_matches(['\r', '\n']); + if line.is_empty() { + if !event.is_empty() { + let value: Value = serde_json::from_str(&event) + .map_err(|_| parse_error("mail MCP stream contains invalid JSON"))?; + event.clear(); + if value.get("id") == Some(&json!(id)) { + return rpc_result(value, id); + } + } + } else if let Some(text) = line.strip_prefix("data:") { + if !event.is_empty() { + event.push('\n'); + } + event.push_str(text.strip_prefix(' ').unwrap_or(text)); + } + } + buffer.drain(..consumed); + } + if sse { + return Err(parse_error( + "mail MCP stream ended before returning a result", + )); + } + let value = serde_json::from_slice(&buffer) + .map_err(|_| parse_error("mail MCP response is not valid JSON"))?; + rpc_result(value, id) +} + +fn tool_result(mut value: Value, tool: &str) -> Result { + if value.get("isError").and_then(Value::as_bool) == Some(true) { + return Err(parse_error( + "mail tool failed; check your search filters or message ID and retry", + )); + } + // MCP supports structured content and JSON text content. Preserve the + // service's result in either representation, without the protocol wrapper. + let payload = if let Some(content) = value.get_mut("structuredContent") { + content.take() + } else { + let text = value + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|item| item.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|item| item.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"); + serde_json::from_str(&text) + .map_err(|_| parse_error("mail tool did not return a JSON result"))? + }; + let key = if tool == "list_mailboxes" { + "mailboxes" + } else { + "emails" + }; + if !payload + .get(key) + .is_some_and(|v| v.is_null() || v.is_array()) + { + return Err(parse_error("mail tool response is missing its result list")); + } + Ok(payload) +} + +/// Keep IDs and server notes visible so terminal output supports the next step. +fn format_pretty(value: &Value) -> String { + let mut lines = Vec::new(); + if value.get("mailboxes").is_some() { + lines.push("UNREAD\tTOTAL\tMAILBOX".into()); + for mailbox in value["mailboxes"].as_array().into_iter().flatten() { + lines.push(format!( + "{}\t{}\t{}", + mailbox["unreadEmails"], + mailbox["totalEmails"], + text(mailbox, "name") + )); + } + } else if value.get("emails").is_some() { + for email in value["emails"].as_array().into_iter().flatten() { + if !lines.is_empty() { + lines.push(String::new()); + } + lines.push(format!( + "{}{}", + if email["unread"] == true { "* " } else { "" }, + text(email, "subject") + )); + for (label, key) in [("ID", "id"), ("Thread", "threadId"), ("Date", "receivedAt")] { + if email.get(key).is_some() { + lines.push(format!("{label}: {}", text(email, key))); + } + } + for (label, key) in [("From", "from"), ("To", "to"), ("Cc", "cc")] { + if let Some(addresses) = email[key].as_array() { + lines.push(format!( + "{label}: {}", + addresses + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(", ") + )); + } + } + let body = ["body", "snippet", "preview"] + .iter() + .find_map(|key| email[key].as_str()) + .unwrap_or_default(); + if !body.is_empty() { + lines.push(body.into()); + } + if email["truncated"] == true { + lines.push("[Message truncated by the service]".into()); + } + for attachment in email["attachments"].as_array().into_iter().flatten() { + lines.push(format!( + "Attachment: {} ({} bytes)", + text(attachment, "name"), + attachment["sizeBytes"] + )); + } + } + if lines.is_empty() { + lines.push("No messages found.".into()); + } + } else if let Some(fields) = value.as_object() { + for (key, value) in fields { + lines.push(format!("{}: {value}", key.replace('_', " "))); + } + } + if let Some(note) = value["note"].as_str() { + lines.push(format!("Note: {note}")); + } + // Mail is untrusted terminal input. Retain line breaks and tabs, but never + // let control bytes in a subject/body issue terminal commands. + lines + .join("\n") + .chars() + .filter(|c| !c.is_control() || matches!(c, '\n' | '\t')) + .collect() +} + +fn text<'a>(value: &'a Value, key: &str) -> &'a str { + value[key].as_str().unwrap_or_default() +} diff --git a/src/main.rs b/src/main.rs index 92b57f3..1f0b626 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,9 @@ mod cli; mod error; mod http; mod local; +mod mail; +#[path = "mail-auth.rs"] +mod mail_auth; mod mcp_install; mod parser; mod quick; @@ -168,7 +171,9 @@ struct ErrorEnvelope { fn error_envelope(error: &KagiError) -> ErrorEnvelope { let (code, category, retryable, detail) = match error { KagiError::Network(message) => ("network_error", "network", true, message.as_str()), - KagiError::Auth(message) => ("authentication_error", "auth", false, message.as_str()), + KagiError::Auth(message) | KagiError::MailAuth(message) => { + ("authentication_error", "auth", false, message.as_str()) + } KagiError::Parse(message) => ("parse_error", "parse", false, message.as_str()), KagiError::Config(message) if message.starts_with("assistant contract") => { ("contract_error", "contract", false, message.as_str()) @@ -187,7 +192,10 @@ fn error_envelope(error: &KagiError) -> ErrorEnvelope { ), KagiError::Batch(message) => ("batch_error", "batch", false, message.as_str()), }; - let required_auth = required_auth_for_message(detail); + let required_auth = match error { + KagiError::MailAuth(_) => Some("KAGI_MAIL_ACCESS_TOKEN"), + _ => required_auth_for_message(detail), + }; ErrorEnvelope { code, @@ -202,7 +210,9 @@ fn error_envelope(error: &KagiError) -> ErrorEnvelope { } fn required_auth_for_message(message: &str) -> Option<&'static str> { - if message.contains("missing credentials") { + if message.contains("KAGI_MAIL_ACCESS_TOKEN") { + Some("KAGI_MAIL_ACCESS_TOKEN") + } else if message.contains("missing credentials") { Some("KAGI_API_KEY or KAGI_SESSION_TOKEN") } else if message.contains("KAGI_API_KEY") { Some("KAGI_API_KEY") @@ -220,6 +230,7 @@ fn suggested_commands_for_error( required_auth: Option<&'static str>, ) -> Vec<&'static str> { match required_auth { + Some("KAGI_MAIL_ACCESS_TOKEN") => vec!["kagi mail status", "kagi mail login"], Some("KAGI_API_KEY") => vec![ "kagi auth status", "kagi auth set --api-key ", @@ -367,6 +378,7 @@ async fn run() -> Result<(), KagiError> { } } } + Commands::Mail(args) => mail::run(args, profile.as_deref()).await, Commands::Agent => { let content = agent::skill_content(agent::KAGI_SKILL).ok_or_else(|| { KagiError::Config("embedded kagi skill is unavailable".to_string()) diff --git a/tests/mail-cli.rs b/tests/mail-cli.rs new file mode 100644 index 0000000..239b0a6 --- /dev/null +++ b/tests/mail-cli.rs @@ -0,0 +1,934 @@ +//! End-to-end mail contract tests against a local HTTP service. The service +//! implements OAuth device/refresh grants and the MCP handshake using fixtures; +//! no live credentials, network services, or mocking framework are involved. + +use std::collections::BTreeMap; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::Path; +use std::process::{Command, Output}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use serde_json::{Value, json}; +use tempfile::TempDir; + +#[derive(Clone, Copy, Default)] +enum Mode { + #[default] + Json, + Sse, + Text, + Unauthorized, + RpcError, + ToolError, + WrongId, + BadPayload, + Denied, + Expired, + NoExpiry, + DeviceError(u16, &'static str), + TokenError(u16, &'static str), +} + +#[derive(Debug)] +struct Request { + method: String, + path: String, + headers: BTreeMap, + body: String, +} + +struct MailService { + url: String, + requests: Arc>>, + stop: Arc, + worker: Option>, +} + +impl MailService { + fn start(mode: Mode) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let stop = Arc::new(AtomicBool::new(false)); + let (thread_url, thread_requests, thread_stop) = + (url.clone(), requests.clone(), stop.clone()); + let worker = thread::spawn(move || { + while !thread_stop.load(Ordering::Relaxed) { + match listener.accept() { + Ok((mut stream, _)) => { + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let request = read_request(&stream); + let reply = serve(&thread_url, &request, mode); + thread_requests.lock().unwrap().push(request); + if reply.content_type == "text/event-stream" { + write!(stream, "HTTP/1.1 {}\r\nContent-Type: text/event-stream\r\nConnection: close\r\n{}Transfer-Encoding: chunked\r\n\r\n", reply.status, reply.headers).unwrap(); + for chunk in reply.body.as_bytes().chunks(7) { + write!(stream, "{:x}\r\n", chunk.len()).unwrap(); + stream.write_all(chunk).unwrap(); + stream.write_all(b"\r\n").unwrap(); + } + stream.flush().unwrap(); + // Leave the HTTP stream open. The CLI must return the + // matching RPC result without waiting for end-of-body. + let mut byte = [0]; + assert_eq!(stream.read(&mut byte).unwrap(), 0); + } else { + write!(stream, "HTTP/1.1 {}\r\nContent-Type: {}\r\nConnection: close\r\n{}Content-Length: {}\r\n\r\n{}", + reply.status, reply.content_type, reply.headers, reply.body.len(), reply.body).unwrap(); + } + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(5)) + } + Err(error) => panic!("fixture listener failed: {error}"), + } + } + }); + Self { + url, + requests, + stop, + worker: Some(worker), + } + } + + fn endpoint(&self) -> String { + format!("{}/mcp", self.url) + } + fn env(&self) -> Vec<(&'static str, String)> { + vec![ + ("KAGI_MAIL_ENDPOINT", self.endpoint()), + ("KAGI_MAIL_ACCESS_TOKEN", "fixture-access".into()), + ] + } + fn calls(&self) -> Vec { + self.requests + .lock() + .unwrap() + .iter() + .filter_map(|r| serde_json::from_str::(&r.body).ok()) + .filter(|v| v["method"] == "tools/call") + .collect() + } +} + +impl Drop for MailService { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + self.worker.take().unwrap().join().unwrap(); + } +} + +fn read_request(stream: &TcpStream) -> Request { + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let mut parts = line.split_whitespace(); + let method = parts.next().unwrap().into(); + let path = parts.next().unwrap().into(); + let mut headers = BTreeMap::new(); + loop { + line.clear(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" { + break; + } + let (key, value) = line.split_once(':').unwrap(); + headers.insert(key.to_ascii_lowercase(), value.trim().into()); + } + let length = headers + .get("content-length") + .map(|s: &String| s.parse().unwrap()) + .unwrap_or(0); + let mut body = vec![0; length]; + reader.read_exact(&mut body).unwrap(); + Request { + method, + path, + headers, + body: String::from_utf8(body).unwrap(), + } +} + +struct Reply { + status: u16, + content_type: &'static str, + headers: String, + body: String, +} +fn reply(status: u16, value: Value) -> Reply { + Reply { + status, + content_type: "application/json", + headers: String::new(), + body: value.to_string(), + } +} + +fn serve(url: &str, request: &Request, mode: Mode) -> Reply { + assert_eq!( + request.headers["user-agent"], + "OpenAI File Downloader, XaiImageApiFetch/1.0" + ); + match (request.method.as_str(), request.path.as_str()) { + ("GET", "/mcp") => { + assert!(!request.headers.contains_key("authorization")); + let mut response = reply(401, json!({})); + response.headers = + format!("WWW-Authenticate: Bearer resource_metadata=\"{url}/resource\"\r\n"); + response + } + ("GET", "/resource") => reply( + 200, + json!({"resource": format!("{url}/mcp"), "authorization_servers": [url], "scopes_supported": ["openid"]}), + ), + ("GET", "/.well-known/oauth-authorization-server") => reply(404, json!({})), + ("GET", "/.well-known/openid-configuration") => reply( + 200, + json!({"issuer": url, "token_endpoint": format!("{url}/token"), "device_authorization_endpoint": format!("{url}/device")}), + ), + ("POST", "/device") => { + let form = form(&request.body); + assert_eq!(form["client_id"], "fixture-client"); + assert_eq!(form["scope"], "openid offline_access"); + if let Mode::DeviceError(status, error) = mode { + return reply( + status, + json!({"error": error, "error_description": "PRIVATE"}), + ); + } + reply( + 200, + json!({"device_code": "fixture-device", "user_code": "\u{0000}AB\tCD\r\n", "verification_uri": format!("{url}/verify"), "interval": 1, "expires_in": if matches!(mode, Mode::Expired) { 0 } else { 30 }}), + ) + } + ("POST", "/token") => { + let form = form(&request.body); + assert_eq!(form["client_id"], "fixture-client"); + if matches!(mode, Mode::Denied) { + return reply( + 400, + json!({"error": "access_denied", "error_description": "PRIVATE"}), + ); + } + let refresh = form["grant_type"] == "refresh_token"; + if refresh { + assert_eq!(form["refresh_token"], "fixture-refresh"); + assert_eq!(form["resource"], format!("{url}/mcp")); + } else { + assert_eq!( + form["grant_type"], + "urn:ietf:params:oauth:grant-type:device_code" + ); + assert_eq!(form["device_code"], "fixture-device"); + } + if let Mode::TokenError(status, error) = mode { + return reply( + status, + json!({"error": error, "error_description": "PRIVATE"}), + ); + } + let mut token = json!({ + "access_token": "fixture-access", + "refresh_token": if refresh { "rotated-refresh" } else { "fixture-refresh" }, + "token_type": "Bearer", + }); + if !matches!(mode, Mode::NoExpiry) { + token["expires_in"] = json!(3600); + } + reply(200, token) + } + ("DELETE", "/mcp") => reply(200, json!({})), + ("POST", "/mcp") => { + if matches!(mode, Mode::Unauthorized) { + return reply(401, json!({"detail": "PRIVATE"})); + } + assert_eq!(request.headers["authorization"], "Bearer fixture-access"); + let rpc: Value = serde_json::from_str(&request.body).unwrap(); + assert_eq!(rpc["jsonrpc"], "2.0"); + let result = match rpc["method"].as_str().unwrap() { + "initialize" => { + assert_eq!(rpc["params"]["clientInfo"]["name"], "kagi-cli"); + json!({"protocolVersion": "2025-11-25", "capabilities": {"tools": {}}, "serverInfo": {"name": "mail-fixture", "version": "1"}}) + } + "notifications/initialized" => { + assert_eq!(request.headers["mcp-session-id"], "fixture-session"); + assert_eq!(request.headers["mcp-protocol-version"], "2025-11-25"); + return reply(202, json!({})); + } + "tools/call" => { + assert_eq!(request.headers["mcp-session-id"], "fixture-session"); + assert_eq!(request.headers["mcp-protocol-version"], "2025-11-25"); + if matches!(mode, Mode::RpcError) { + return reply( + 200, + json!({"jsonrpc": "2.0", "id": 2, "error": {"code": -32602, "message": "PRIVATE"}}), + ); + } + if matches!(mode, Mode::ToolError) { + return reply( + 200, + json!({"jsonrpc": "2.0", "id": 2, "result": {"isError": true, "content": [{"type": "text", "text": "PRIVATE"}]}}), + ); + } + let payload = match rpc["params"]["name"].as_str().unwrap() { + "list_mailboxes" => { + json!({"mailboxes": [{"name": "Inbox", "role": "inbox", "totalEmails": 4, "unreadEmails": 2}]}) + } + "search_email" | "semantic_search" => { + json!({"emails": [{"id": "m1", "threadId": "t1", "subject": "Invoice", "from": ["sender@example.com"], "preview": "Example message", "unread": true}], "note": "fixture note"}) + } + "get_email" => { + json!({"emails": [{"id": "m1", "threadId": "t1", "subject": "Invoice\u{001b}[31m", "body": "Hello world, Résumé", "truncated": true, "attachments": [{"name": "invoice.pdf", "sizeBytes": 42}]}]}) + } + name => panic!("unexpected mail tool {name}"), + }; + match mode { + Mode::Text => { + json!({"content": [{"type": "text", "text": payload.to_string()}]}) + } + Mode::BadPayload => json!({"structuredContent": {"emails": "invalid"}}), + _ => json!({"structuredContent": payload}), + } + } + method => panic!("unexpected MCP method {method}"), + }; + let id = if matches!(mode, Mode::WrongId) { + json!(999) + } else { + rpc["id"].clone() + }; + let envelope = json!({"jsonrpc": "2.0", "id": id, "result": result}); + let mut response = reply(200, envelope.clone()); + response.headers = "Mcp-Session-Id: fixture-session\r\n".into(); + if matches!(mode, Mode::Sse) { + response.content_type = "text/event-stream"; + response.body = format!( + ": keepalive\r\n\r\ndata: {{\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\"}}\r\n\r\nevent: message\r\ndata: {envelope}\r\n\r\n" + ); + } + response + } + route => panic!("unexpected service route {route:?}"), + } +} + +fn form(body: &str) -> BTreeMap { + reqwest::Url::parse(&format!("http://localhost/?{body}")) + .unwrap() + .query_pairs() + .into_owned() + .collect() +} + +fn run(args: &[&str], directory: &Path, env: &[(&str, String)]) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_kagi")); + command.args(args).current_dir(directory); + for key in [ + "KAGI_MAIL_ENDPOINT", + "KAGI_MAIL_CLIENT_ID", + "KAGI_MAIL_ACCESS_TOKEN", + "KAGI_API_KEY", + "KAGI_API_TOKEN", + "KAGI_SESSION_TOKEN", + "KAGI_ERROR_FORMAT", + "RUST_LOG", + ] { + command.env_remove(key); + } + command.env("KAGI_CONFIG", directory.join("config.toml")); + for (key, value) in env { + command.env(key, value); + } + command.output().unwrap() +} + +fn success(output: Output) -> Value { + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stderr.is_empty()); + serde_json::from_slice(&output.stdout).unwrap() +} + +#[test] +fn maps_all_search_filters_and_semantics() { + let service = MailService::start(Mode::Json); + let dir = TempDir::new().unwrap(); + let value = success(run( + &[ + "mail", + "search", + "invoice", + "--from", + "a@example.com", + "--to", + "b@example.com", + "--cc", + "c@example.com", + "--subject", + "bill", + "--mailbox", + "Projects/2026", + "--after", + "2026-01-01", + "--before", + "2026-02-01", + "--unread", + "--has-attachment", + "--min-size", + "1000", + "--limit", + "5", + ], + dir.path(), + &service.env(), + )); + assert_eq!(value["emails"][0]["id"], "m1"); + assert_eq!(value["note"], "fixture note"); + let calls = service.calls(); + assert_eq!( + calls[0]["params"], + json!({"name":"search_email", "arguments": {"text":"invoice", "from":"a@example.com", "to":"b@example.com", "cc":"c@example.com", "subject":"bill", "mailbox":"Projects/2026", "after":"2026-01-01", "before":"2026-02-01", "unreadOnly":true, "hasAttachment":true, "minSizeBytes":1000, "limit":5}}) + ); + success(run( + &[ + "mail", + "search", + "renewal discussion", + "--semantic", + "--from", + "a@example.com", + ], + dir.path(), + &service.env(), + )); + assert_eq!( + service.calls()[1]["params"], + json!({"name":"semantic_search", "arguments":{"query":"renewal discussion", "from":"a@example.com", "limit":20}}) + ); + success(run(&["mail", "search"], dir.path(), &service.env())); + assert_eq!( + service.calls()[2]["params"]["arguments"], + json!({"limit":20}) + ); +} + +#[test] +fn reads_messages_threads_and_boxes_with_both_mcp_representations() { + for mode in [Mode::Json, Mode::Sse, Mode::Text] { + let service = MailService::start(mode); + let dir = TempDir::new().unwrap(); + let boxes = success(run( + &["mail", "--format", "compact", "boxes"], + dir.path(), + &service.env(), + )); + assert_eq!(boxes["mailboxes"][0]["unreadEmails"], 2); + success(run(&["mail", "read", "m1"], dir.path(), &service.env())); + assert_eq!( + service.calls()[1]["params"]["arguments"], + json!({"emailId":"m1", "newTextOnly":false}) + ); + success(run( + &["mail", "read", "--thread", "t1", "--new-text-only"], + dir.path(), + &service.env(), + )); + assert_eq!( + service.calls()[2]["params"]["arguments"], + json!({"threadId":"t1", "newTextOnly":true}) + ); + } +} + +#[test] +fn validates_arguments_before_authentication() { + let dir = TempDir::new().unwrap(); + for args in [ + vec!["mail"], + vec!["mail", "search", "--semantic"], + vec!["mail", "search", "--limit", "0"], + vec!["mail", "search", "--limit", "51"], + vec!["mail", "read"], + vec!["mail", "read", "m1", "--thread", "t1"], + ] { + let output = run(&args, dir.path(), &[]); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + } + let help = run(&["mail", "--help"], dir.path(), &[]); + assert!(help.status.success()); + assert!(String::from_utf8_lossy(&help.stdout).contains("--semantic")); +} + +#[test] +fn errors_are_redacted_and_machine_readable() { + let dir = TempDir::new().unwrap(); + for mode in [ + Mode::Unauthorized, + Mode::RpcError, + Mode::ToolError, + Mode::WrongId, + Mode::BadPayload, + ] { + let service = MailService::start(mode); + let output = run( + &["mail", "search", "--error-format", "json"], + dir.path(), + &service.env(), + ); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(!stderr.contains("PRIVATE")); + assert!(!stderr.contains(&service.url)); + assert!(!stderr.contains("fixture-access")); + let error: Value = serde_json::from_str(&stderr).unwrap(); + if matches!(mode, Mode::Unauthorized) { + assert_eq!(error["required_auth"], "KAGI_MAIL_ACCESS_TOKEN"); + assert!( + error["suggested_commands"] + .as_array() + .unwrap() + .contains(&json!("kagi mail login")) + ); + } + } +} + +#[test] +fn pretty_output_keeps_ids_notes_and_truncation_without_terminal_escapes() { + let service = MailService::start(Mode::Json); + let dir = TempDir::new().unwrap(); + let output = run( + &["mail", "read", "m1", "--format", "pretty"], + dir.path(), + &service.env(), + ); + assert!(output.status.success()); + let text = String::from_utf8(output.stdout).unwrap(); + for expected in [ + "ID: m1", + "Thread: t1", + "Hello world", + "[Message truncated", + "invoice.pdf", + ] { + assert!(text.contains(expected)); + } + assert!(!text.contains('\u{001b}')); + let output = run( + &["mail", "search", "--format", "pretty"], + dir.path(), + &service.env(), + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("Note: fixture note")); +} + +fn write_config(directory: &Path, service: &MailService, mail_tokens: &str) { + std::fs::write( + directory.join("config.toml"), + format!( + r#" +[auth] +api_key = 'search-key' +[mail] +endpoint = '{base}/mcp' +client_id = 'fixture-client' +issuer = '{base}' +{mail_tokens} +[profiles.work.auth] +api_token = 'other-search-token' +[profiles.work.mail] +endpoint = '{base}/mcp' +client_id = 'fixture-client' +access_token = 'work-token' +"#, + base = service.url + ), + ) + .unwrap(); +} + +#[test] +fn refresh_rotates_tokens_and_preserves_other_credentials() { + let service = MailService::start(Mode::Json); + let dir = TempDir::new().unwrap(); + write_config( + dir.path(), + &service, + "access_token = 'expired'\nrefresh_token = 'fixture-refresh'\nexpires_at = 1", + ); + success(run(&["mail", "boxes"], dir.path(), &[])); + let saved: toml::Value = + toml::from_str(&std::fs::read_to_string(dir.path().join("config.toml")).unwrap()).unwrap(); + assert_eq!( + saved["mail"]["refresh_token"].as_str(), + Some("rotated-refresh") + ); + assert_eq!(saved["auth"]["api_key"].as_str(), Some("search-key")); + assert_eq!( + saved["profiles"]["work"]["mail"]["access_token"].as_str(), + Some("work-token") + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(dir.path().join("config.toml")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + success(run(&["mail", "boxes"], dir.path(), &[])); + assert_eq!( + service + .requests + .lock() + .unwrap() + .iter() + .filter(|r| r.path == "/token") + .count(), + 1 + ); + // Saving a search key must not erase the newly stored mail credentials. + assert!( + run( + &["auth", "set", "--api-key", "replacement-key"], + dir.path(), + &[] + ) + .status + .success() + ); + let saved = std::fs::read_to_string(dir.path().join("config.toml")).unwrap(); + assert!(saved.contains("rotated-refresh")); +} + +#[test] +fn login_saves_tokens_and_logout_preserves_connection_and_profiles() { + let service = MailService::start(Mode::Json); + let dir = TempDir::new().unwrap(); + write_config(dir.path(), &service, ""); + let output = run(&["mail", "login"], dir.path(), &[]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + serde_json::from_slice::(&output.stdout).unwrap(), + json!({"authenticated":true}) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Code: ABCD\nWaiting for approval.")); + assert!(!stderr.chars().any(|c| c.is_control() && c != '\n')); + success(run(&["mail", "boxes"], dir.path(), &[])); + let status = success(run(&["mail", "status"], dir.path(), &[])); + assert_eq!(status["access_token_configured"], true); + assert_eq!(status["expired"], false); + success(run(&["mail", "logout"], dir.path(), &[])); + let saved: toml::Value = + toml::from_str(&std::fs::read_to_string(dir.path().join("config.toml")).unwrap()).unwrap(); + assert!(saved["mail"].get("access_token").is_none()); + assert_eq!( + saved["mail"]["endpoint"].as_str(), + Some(service.endpoint().as_str()) + ); + assert_eq!( + saved["profiles"]["work"]["mail"]["access_token"].as_str(), + Some("work-token") + ); +} + +#[test] +fn failed_login_keeps_saved_credentials() { + for (mode, category, retryable) in [ + (Mode::Denied, "auth", false), + (Mode::Expired, "auth", false), + (Mode::TokenError(400, "invalid_grant"), "auth", false), + ( + Mode::TokenError(429, "temporarily_unavailable"), + "network", + true, + ), + (Mode::TokenError(500, "server_error"), "network", true), + ( + Mode::TokenError(400, "invalid_client"), + "configuration", + false, + ), + ( + Mode::TokenError(400, "unauthorized_client"), + "configuration", + false, + ), + ( + Mode::TokenError(401, "invalid_client"), + "configuration", + false, + ), + ( + Mode::DeviceError(400, "invalid_client"), + "configuration", + false, + ), + ( + Mode::DeviceError(401, "invalid_client"), + "configuration", + false, + ), + ( + Mode::TokenError(400, "temporarily_unavailable"), + "network", + true, + ), + ] { + let service = MailService::start(mode); + let dir = TempDir::new().unwrap(); + write_config(dir.path(), &service, "access_token = 'keep-me'"); + let before = std::fs::read(dir.path().join("config.toml")).unwrap(); + let output = run( + &["mail", "login", "--error-format", "json"], + dir.path(), + &[], + ); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + assert!(!String::from_utf8_lossy(&output.stderr).contains("PRIVATE")); + // Device instructions precede the final structured error on stderr. + let stderr = String::from_utf8(output.stderr).unwrap(); + let error: Value = serde_json::from_str(stderr.lines().last().unwrap()).unwrap(); + assert_eq!(error["category"], category); + assert_eq!(error["retryable"], retryable); + if category == "auth" { + assert_eq!(error["required_auth"], "KAGI_MAIL_ACCESS_TOKEN"); + assert_eq!( + error["suggested_commands"], + json!(["kagi mail status", "kagi mail login"]) + ); + assert!( + error["message"] + .as_str() + .unwrap() + .contains("run `kagi mail login`") + ); + } else if let Mode::TokenError(status, _) | Mode::DeviceError(status, _) = mode { + assert_eq!(error["http_status"], status); + assert_eq!(error["suggested_commands"], json!([])); + if category == "configuration" { + assert!( + error["message"] + .as_str() + .unwrap() + .contains("KAGI_MAIL_CLIENT_ID") + ); + } + } + assert_eq!( + std::fs::read(dir.path().join("config.toml")).unwrap(), + before + ); + } +} + +#[test] +fn profile_and_environment_overrides_do_not_reuse_another_accounts_tokens() { + let service = MailService::start(Mode::Json); + let dir = TempDir::new().unwrap(); + write_config(dir.path(), &service, "access_token = 'default-token'"); + let status = success(run( + &["mail", "status", "--profile", "missing"], + dir.path(), + &[], + )); + assert_eq!(status["access_token_configured"], false); + assert_eq!(status["endpoint_configured"], false); + let status = success(run( + &["mail", "status"], + dir.path(), + &[("KAGI_MAIL_ENDPOINT", "https://different.example/mcp".into())], + )); + assert_eq!(status["access_token_configured"], false); + success(run( + &["mail", "boxes", "--profile", "work"], + dir.path(), + &[("KAGI_MAIL_ACCESS_TOKEN", "fixture-access".into())], + )); + let output = run(&["mail", "status"], dir.path(), &[]); + let text = String::from_utf8(output.stdout).unwrap(); + assert!(!text.contains("default-token")); + assert!(!text.contains("fixture-client")); + assert!(!text.contains(&service.url)); +} + +#[test] +fn refresh_rejects_a_different_issuer_without_sending_tokens() { + let service = MailService::start(Mode::Json); + let dir = TempDir::new().unwrap(); + write_config( + dir.path(), + &service, + "access_token = 'expired'\nrefresh_token = 'fixture-refresh'\nexpires_at = 1", + ); + let path = dir.path().join("config.toml"); + let config = std::fs::read_to_string(&path).unwrap().replace( + &format!("issuer = '{}'", service.url), + "issuer = 'https://other.example'", + ); + std::fs::write(&path, &config).unwrap(); + let output = run( + &["mail", "boxes", "--error-format", "json"], + dir.path(), + &[], + ); + assert_eq!(output.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&output.stderr).contains("issuer changed")); + let error: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(error["required_auth"], "KAGI_MAIL_ACCESS_TOKEN"); + assert_eq!( + error["suggested_commands"], + json!(["kagi mail status", "kagi mail login"]) + ); + assert!( + !service + .requests + .lock() + .unwrap() + .iter() + .any(|r| r.path == "/token") + ); + assert_eq!(std::fs::read_to_string(path).unwrap(), config); +} + +#[test] +fn refresh_errors_preserve_tokens_and_distinguish_retryable_failures() { + for (status, oauth_error, category, retryable) in [ + (429, "temporarily_unavailable", "network", true), + (500, "server_error", "network", true), + (400, "temporarily_unavailable", "network", true), + (400, "invalid_grant", "auth", false), + (400, "invalid_client", "configuration", false), + (400, "unauthorized_client", "configuration", false), + (401, "invalid_client", "configuration", false), + (400, "invalid_request", "configuration", false), + ] { + let service = MailService::start(Mode::TokenError(status, oauth_error)); + let dir = TempDir::new().unwrap(); + write_config( + dir.path(), + &service, + "access_token = 'expired'\nrefresh_token = 'fixture-refresh'\nexpires_at = 1", + ); + let path = dir.path().join("config.toml"); + let before = std::fs::read(&path).unwrap(); + let output = run( + &["mail", "boxes", "--error-format", "json"], + dir.path(), + &[], + ); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(!stderr.contains("PRIVATE")); + assert!(!stderr.contains(&service.url)); + let error: Value = serde_json::from_str(&stderr).unwrap(); + assert_eq!(error["category"], category); + assert_eq!(error["retryable"], retryable); + if matches!(oauth_error, "invalid_client" | "unauthorized_client") { + assert!( + error["message"] + .as_str() + .unwrap() + .contains("KAGI_MAIL_CLIENT_ID") + ); + } + if category == "network" { + assert_eq!(error["http_status"], status); + assert_eq!(error["suggested_commands"], json!([])); + } + assert_eq!(std::fs::read(&path).unwrap(), before); + } +} + +#[test] +fn login_without_expiry_refreshes_before_reading_mail() { + let service = MailService::start(Mode::NoExpiry); + let dir = TempDir::new().unwrap(); + write_config(dir.path(), &service, ""); + let login = run(&["mail", "login"], dir.path(), &[]); + assert!( + login.status.success(), + "{}", + String::from_utf8_lossy(&login.stderr) + ); + assert_eq!( + serde_json::from_slice::(&login.stdout).unwrap(), + json!({"authenticated": true}) + ); + let saved: toml::Value = + toml::from_str(&std::fs::read_to_string(dir.path().join("config.toml")).unwrap()).unwrap(); + assert!(saved["mail"].get("expires_at").is_none()); + success(run(&["mail", "boxes"], dir.path(), &[])); + let grants: Vec<_> = service + .requests + .lock() + .unwrap() + .iter() + .filter(|request| request.path == "/token") + .map(|request| form(&request.body)["grant_type"].clone()) + .collect(); + assert_eq!( + grants, + [ + "urn:ietf:params:oauth:grant-type:device_code", + "refresh_token" + ] + ); +} + +#[test] +fn malformed_config_and_insecure_urls_do_not_echo_private_values() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("config.toml"), + "[mail]\naccess_token = PRIVATE-SECRET", + ) + .unwrap(); + let output = run(&["mail", "status"], dir.path(), &[]); + assert_eq!(output.status.code(), Some(1)); + assert!(!String::from_utf8_lossy(&output.stderr).contains("PRIVATE-SECRET")); + std::fs::write(dir.path().join("config.toml"), "").unwrap(); + for url in [ + "http://private.example/mcp", + "https://user:PRIVATE@private.example/mcp", + "https://private.example/mcp#PRIVATE", + ] { + let output = run( + &["mail", "boxes"], + dir.path(), + &[ + ("KAGI_MAIL_ENDPOINT", url.into()), + ("KAGI_MAIL_ACCESS_TOKEN", "PRIVATE".into()), + ], + ); + assert_eq!(output.status.code(), Some(1)); + assert!(!String::from_utf8_lossy(&output.stderr).contains("PRIVATE")); + assert!(!String::from_utf8_lossy(&output.stderr).contains("private.example")); + } +}