Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,14 +135,35 @@ 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 |
| --- | --- |
| `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:

Expand Down Expand Up @@ -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 |
Expand Down
88 changes: 88 additions & 0 deletions docs/content/docs/commands/mail.mdx
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions docs/content/docs/commands/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"---Content & Feeds",
"news",
"smallweb",
"mail",
"---AI & Enrichment",
"assistant",
"quick",
Expand Down
4 changes: 3 additions & 1 deletion docs/content/docs/reference/auth-matrix.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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:**
Expand Down
20 changes: 20 additions & 0 deletions skills/kagi-usage/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` for the same guidance.
Expand Down Expand Up @@ -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.
75 changes: 69 additions & 6 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -241,6 +242,8 @@ impl CredentialInventory {
#[derive(Debug, Default, Deserialize, serde::Serialize)]
struct ConfigFile {
auth: Option<AuthConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
mail: Option<crate::mail_auth::MailConfig>,
profiles: Option<BTreeMap<String, ProfileConfig>>,
}

Expand All @@ -255,6 +258,52 @@ struct AuthConfig {
#[derive(Debug, Default, Deserialize, serde::Serialize)]
struct ProfileConfig {
auth: Option<AuthConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
mail: Option<crate::mail_auth::MailConfig>,
}

/// Mail has its own OAuth credentials, isolated from search auth and other profiles.
pub fn load_mail_config(profile: Option<&str>) -> Result<crate::mail_auth::MailConfig, KagiError> {
// 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)]
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading