diff --git a/CHANGELOG.md b/CHANGELOG.md index d3eb81e..3e59b61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## [Unreleased] +### Added + +- `kagi mail send` submits plain-text email through Kagi Mail's SMTP endpoint, with separate environment-only credentials and required verified STARTTLS. + ## [0.20.1] ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 410878d..ade2936 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,6 +148,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bitflags" version = "2.11.0" @@ -481,6 +487,22 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" +[[package]] +name = "email-encoding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" +dependencies = [ + "base64 0.23.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -714,7 +736,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "headers-core", "http", @@ -802,7 +824,7 @@ dependencies = [ "assert-json-diff", "async-object-pool", "async-trait", - "base64", + "base64 0.22.1", "bytes", "crossbeam-utils", "form_urlencoded", @@ -872,7 +894,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1076,7 +1098,9 @@ dependencies = [ "futures-util", "httpmock", "jsonc-parser", + "lettre", "mime_guess", + "parking_lot", "reqwest", "scraper", "serde", @@ -1096,6 +1120,33 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lettre" +version = "0.11.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae" +dependencies = [ + "async-trait", + "base64 0.23.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots", +] + [[package]] name = "libc" version = "0.2.183" @@ -1216,6 +1267,15 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1458,6 +1518,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" @@ -1537,7 +1603,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -1621,6 +1687,7 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", diff --git a/Cargo.toml b/Cargo.toml index e308397..a2b0280 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ reqwest = { version = "0.12.15", default-features = false, features = ["brotli", futures-util = "0.3.34" fs2 = "0.4" jsonc-parser = { version = "0.33.1", features = ["serde"] } +lettre = { version = "0.11.23", default-features = false, features = ["builder", "smtp-transport", "tokio1-rustls", "ring", "webpki-roots"] } scraper = "0.27.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.151" @@ -45,4 +46,5 @@ toon = "0.1.2" [dev-dependencies] httpmock = "0.8.3" +parking_lot = "0.12.5" tempfile = "3.27.0" diff --git a/README.md b/README.md index 77e4ae7..c4f5dbc 100644 --- a/README.md +++ b/README.md @@ -149,11 +149,31 @@ 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. +Read-only MCP access uses separate OAuth credentials and supports `--profile`. +Mail defaults to JSON and supports `--format compact|toon|pretty`. Messages are +not added to the local history or response cache. + +To send plain-text mail, supply SMTP credentials only through the environment +variables `KAGI_MAIL_SMTP_USERNAME` and `KAGI_MAIL_SMTP_PASSWORD`, then run: + +```bash +kagi mail send \ + --from sender@example.com \ + --to recipient@example.com \ + --subject "Meeting notes" \ + --body "The meeting starts at 10." +``` + +All four flags, `--from`, `--to`, `--subject`, and `--body`, are required. +Sending uses `mail.kagimail.com:587` with required, certificate-verified STARTTLS, +with no plaintext or unverified TLS fallback. HTML and attachments are not +supported. SMTP credentials are env-only, not command-line flags or saved +config. The read-only MCP OAuth token is separate and is never reused for +sending; `kagi mail login` does not supply SMTP credentials. SMTP errors do not +echo credentials, message content, or remote response text. + See the [mail command reference](docs/content/docs/commands/mail.mdx) for setup, -filters, and token handling. +filters, sending, and token handling. ## auth model @@ -163,6 +183,7 @@ filters, and token handling. | `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 | | saved mail OAuth tokens or `KAGI_MAIL_ACCESS_TOKEN` | `mail boxes`, `mail search`, and `mail read` | +| `KAGI_MAIL_SMTP_USERNAME` and `KAGI_MAIL_SMTP_PASSWORD` | `mail send` only, through SMTP | | none | `news`, `smallweb`, `auth status`, `mail status`, `--help` | example config: @@ -211,7 +232,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 mail` | list mailboxes, search and read mail with separate OAuth login, or send plain-text mail through SMTP | | `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 index 3bd6639..51e6b00 100644 --- a/docs/content/docs/commands/mail.mdx +++ b/docs/content/docs/commands/mail.mdx @@ -1,13 +1,14 @@ --- title: mail -description: Search and read Kagi Mail from the terminal. +description: Search, read, and send 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. +through the read-only mail MCP. These operations do not send, delete, move, or +mark mail as read. `kagi mail send` submits plain-text mail through SMTP instead. +Messages and responses are not stored in local history or the response cache. -## Setup +## Read-only MCP setup Add the MCP endpoint and OAuth client ID supplied by your mail service to your private `~/.config/kagi-cli/config.toml`: @@ -68,18 +69,51 @@ YYYY-MM-DD or RFC 3339. `--after` is inclusive and `--before` is exclusive. oldest first. `--new-text-only` drops quoted reply history. Attachment metadata is included when available; attachment download is not exposed by the MCP. +## Send mail + +`kagi mail send` requires all four flags: `--from`, `--to`, `--subject`, and +`--body`. Use one bare email address for each of `--from` and `--to`, without +display names. The subject must not contain control characters. + +```bash +kagi mail send \ + --from sender@example.com \ + --to recipient@example.com \ + --subject "Meeting notes" \ + --body "The meeting starts at 10." +``` + +Supply SMTP credentials only through the environment variables +`KAGI_MAIL_SMTP_USERNAME` and `KAGI_MAIL_SMTP_PASSWORD`. There are no credential +flags or saved-config credentials for sending. Do not put credentials on the +command line. + +Sending uses `mail.kagimail.com:587` with required, certificate-verified STARTTLS. +It never falls back to plaintext or unverified TLS. Mail is plain-text only; +HTML and attachments are not supported. + +The read-only MCP OAuth token is separate and is never reused for SMTP. +`kagi mail login`, saved profiles, and `KAGI_MAIL_ACCESS_TOKEN` do not supply +sending credentials. + +Successful submission returns `{"status":"submitted"}`. SMTP acceptance does +not prove recipient delivery. If submission fails, delivery may be unknown; +check your mailbox before retrying to avoid duplicate mail. SMTP errors do not +echo credentials, message content, or remote response text. Authentication +rejections identify the SMTP environment variables, not the MCP login flow. + ## 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. +works before or after the mail subcommand. For MCP reads, 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 for MCP reads 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 diff --git a/src/cli.rs b/src/cli.rs index 7471381..8ea0d70 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -323,7 +323,7 @@ pub enum Commands { /// Inspect account plan, AI allowance, renewal, and calendar-month usage #[command(visible_alias = "billing")] Usage(UsageArgs), - /// Search and read Kagi Mail + /// Search, read, or explicitly send Kagi Mail Mail(crate::mail::MailCommand), /// Summarize a URL or text with Kagi's public API or subscriber web Summarizer Summarize(SummarizeArgs), diff --git a/src/error.rs b/src/error.rs index f6e88e2..b72906f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -25,6 +25,10 @@ pub enum KagiError { #[error("authentication error: {0}")] MailAuth(String), + /// SMTP submission failed; retrying could duplicate a message. + #[error("mail send error: {0}")] + MailSend(String), + /// A data parsing or deserialization failure. #[error("parse error: {0}")] Parse(String), diff --git a/src/local.rs b/src/local.rs index 49f5904..e34d6ef 100644 --- a/src/local.rs +++ b/src/local.rs @@ -354,6 +354,7 @@ fn write_json_locked(path: &Path, value: &T) -> Result<(), KagiErr fn open_locked_append(path: &Path) -> Result { let file = fs::OpenOptions::new() .create(true) + .read(true) .append(true) .open(path) .map_err(|error| { diff --git a/src/mail-send.rs b/src/mail-send.rs new file mode 100644 index 0000000..3ccafde --- /dev/null +++ b/src/mail-send.rs @@ -0,0 +1,349 @@ +//! Explicit SMTP submission. Never loads MCP OAuth or persists credentials/mail. + +use std::{env, fmt, time::Duration}; + +use clap::Args; +use lettre::{ + Address, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, + message::{Mailbox, header::ContentType}, + transport::smtp::{ + AsyncSmtpTransportBuilder, + authentication::{Credentials, Mechanism}, + }, +}; +use serde_json::{Value, json}; + +use crate::error::KagiError; + +const SMTP_HOST: &str = "mail.kagimail.com"; +const SMTP_PORT: u16 = 587; +const SMTP_USERNAME: &str = "KAGI_MAIL_SMTP_USERNAME"; +const SMTP_PASSWORD: &str = "KAGI_MAIL_SMTP_PASSWORD"; + +#[derive(Args)] +#[command( + after_help = "Requires KAGI_MAIL_SMTP_USERNAME and KAGI_MAIL_SMTP_PASSWORD in the environment.\nUses mail.kagimail.com:587 with required, certificate-verified STARTTLS.\nMCP OAuth tokens and saved profiles are never used for sending." +)] +pub struct MailSendArgs { + /// Sender email address (one bare address, no display name) + #[arg(long, value_name = "ADDRESS")] + pub from: String, + /// Recipient email address (one bare address, no display name) + #[arg(long, value_name = "ADDRESS")] + pub to: String, + /// Subject text (no control characters) + #[arg(long, value_name = "TEXT")] + pub subject: String, + /// Plain-text message body; quote multiline text in your shell + #[arg(long, value_name = "TEXT", allow_hyphen_values = true)] + pub body: String, +} + +// Cli derives Debug; keep message content out of any diagnostic formatting. +impl fmt::Debug for MailSendArgs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MailSendArgs").finish_non_exhaustive() + } +} + +impl MailSendArgs { + fn message(self) -> Result { + let from = address(&self.from, "--from")?; + let to = address(&self.to, "--to")?; + if self.subject.chars().any(char::is_control) { + return Err(KagiError::Config( + "mail send --subject must not contain control characters".into(), + )); + } + Message::builder() + .from(Mailbox::new(None, from)) + .to(Mailbox::new(None, to)) + .subject(self.subject) + .header(ContentType::TEXT_PLAIN) + .body(self.body) + .map_err(|_| KagiError::Config("mail send could not construct the message".into())) + } +} + +fn address(value: &str, flag: &str) -> Result { + if value.chars().any(char::is_control) { + return Err(KagiError::Config(format!( + "mail send {flag} requires one valid bare email address" + ))); + } + value.parse().map_err(|_| { + KagiError::Config(format!( + "mail send {flag} requires one valid bare email address" + )) + }) +} + +fn credentials( + mut lookup: impl FnMut(&str) -> Result, +) -> Result { + let mut read = |name: &str| { + lookup(name) + .ok() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + KagiError::Config(format!( + "missing credentials: set {name} to a nonempty UTF-8 value for SMTP; mail login tokens cannot be used" + )) + }) + }; + Ok(Credentials::new(read(SMTP_USERNAME)?, read(SMTP_PASSWORD)?)) +} + +fn transport_builder( + host: &str, + port: u16, + credentials: Credentials, +) -> Result { + // starttls_relay requires TLS before AUTH/MAIL and verifies the certificate + // against host using Mozilla roots. No plaintext or insecure fallback. + AsyncSmtpTransport::::starttls_relay(host) + .map(|builder| { + builder + .port(port) + .credentials(credentials) + .authentication(vec![Mechanism::Plain, Mechanism::Login]) + .timeout(Some(Duration::from_secs(30))) + }) + .map_err(|_| KagiError::MailSend("could not initialize verified SMTP STARTTLS".into())) +} + +fn submission_error(error: lettre::transport::smtp::Error) -> KagiError { + // SMTP responses and underlying errors can echo credentials or message text. + // Inspect only the status code, never format or log the upstream error. + if error + .status() + .is_some_and(|status| matches!(u16::from(status), 530 | 534 | 535)) + { + KagiError::Auth( + "SMTP authentication rejected; check KAGI_MAIL_SMTP_USERNAME and KAGI_MAIL_SMTP_PASSWORD; MCP OAuth tokens cannot be used".into(), + ) + } else { + KagiError::MailSend( + "SMTP submission failed; delivery may be unknown. Check your mailbox before retrying to avoid duplicate mail".into(), + ) + } +} + +pub async fn send(args: MailSendArgs) -> Result { + // Validate the whole message before loading credentials or opening a socket. + let message = args.message()?; + let credentials = credentials(|name| env::var(name))?; + let transport = transport_builder(SMTP_HOST, SMTP_PORT, credentials)?.build::(); + transport.send(message).await.map_err(submission_error)?; + // Acceptance by SMTP is not proof of recipient delivery. Do not echo content. + Ok(json!({"status": "submitted"})) +} + +#[cfg(test)] +mod tests { + use std::{ + io::{BufRead, BufReader, Write}, + net::TcpListener, + thread, + time::Instant, + }; + + use super::*; + + fn args() -> MailSendArgs { + MailSendArgs { + from: "sender@example.com".into(), + to: "recipient@example.com".into(), + subject: "A plain-text message".into(), + body: "First line\n.\nBcc: not-a-header@example.com\nLast line".into(), + } + } + + #[test] + fn serializes_plain_text_with_matching_envelope_and_normalized_newlines() { + let message = args().message().unwrap(); + assert_eq!( + message.envelope().from().unwrap().to_string(), + "sender@example.com" + ); + assert_eq!( + message.envelope().to(), + &["recipient@example.com".parse::
().unwrap()] + ); + let formatted = String::from_utf8(message.formatted()).unwrap(); + let (headers, body) = formatted.split_once("\r\n\r\n").unwrap(); + assert!(headers.contains("From: sender@example.com\r\n")); + assert!(headers.contains("To: recipient@example.com\r\n")); + assert!(headers.contains("Subject: A plain-text message\r\n")); + assert!(headers.contains("Content-Type: text/plain; charset=utf-8")); + assert!(!headers.contains("Bcc:")); + assert!(!headers.contains("Cc:")); + assert!(!headers.contains("Reply-To:")); + assert_eq!( + body, + "First line\r\n.\r\nBcc: not-a-header@example.com\r\nLast line" + ); + } + + #[test] + fn rejects_invalid_addresses_and_header_injection_without_echoing_content() { + for invalid in [ + "", + "PRIVATE", + "PRIVATE ", + "sender@example.com,PRIVATE@example.com", + "sender@example.com\r\nBcc: PRIVATE@example.com", + ] { + for sender in [true, false] { + let mut input = args(); + if sender { + input.from = invalid.into(); + } else { + input.to = invalid.into(); + } + let error = input.message().unwrap_err().to_string(); + assert!(error.contains(if sender { "--from" } else { "--to" })); + assert!(!error.contains("PRIVATE")); + } + } + for subject in [ + "PRIVATE\r\nBcc: other@example.com", + "PRIVATE\0", + "PRIVATE\t", + ] { + let mut input = args(); + input.subject = subject.into(); + let error = input.message().unwrap_err().to_string(); + assert!(error.contains("--subject")); + assert!(!error.contains("PRIVATE")); + } + } + + #[test] + fn rejects_missing_empty_and_non_utf8_smtp_credentials() { + for name in [SMTP_USERNAME, SMTP_PASSWORD] { + for value in [None, Some(""), Some(" \t\n")] { + let error = credentials(|key| { + if key == name { + value.map(str::to_owned).ok_or(env::VarError::NotPresent) + } else { + Ok("PRIVATE".into()) + } + }) + .unwrap_err() + .to_string(); + assert!(error.contains(name)); + assert!(!error.contains("PRIVATE")); + } + let error = credentials(|key| { + if key == name { + Err(env::VarError::NotUnicode("PRIVATE".into())) + } else { + Ok("PRIVATE".into()) + } + }) + .unwrap_err() + .to_string(); + assert!(error.contains(name)); + assert!(!error.contains("PRIVATE")); + } + } + + #[test] + fn preserves_whitespace_in_nonempty_passwords() { + let credentials = credentials(|name| { + Ok(if name == SMTP_USERNAME { + "user".into() + } else { + " password ".into() + }) + }) + .unwrap(); + assert_eq!( + Mechanism::Plain.response(&credentials, None).unwrap(), + "\0user\0 password " + ); + } + + #[tokio::test] + async fn refuses_missing_or_rejected_starttls_before_authentication_or_message() { + for advertises_starttls in [false, true] { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + listener.set_nonblocking(true).unwrap(); + let server = thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(5); + let (mut stream, _) = loop { + match listener.accept() { + Ok(connection) => break connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "local SMTP connection timed out"); + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("local SMTP accept failed: {error}"), + } + }; + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(5))) + .unwrap(); + stream.write_all(b"220 localhost ESMTP\r\n").unwrap(); + let mut reader = BufReader::new(stream); + let mut transcript = String::new(); + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + transcript.push_str(&line); + if line.starts_with("EHLO ") { + let reply = if advertises_starttls { + "250-localhost\r\n250-STARTTLS\r\n250 AUTH PLAIN LOGIN XOAUTH2\r\n" + } else { + "250-localhost\r\n250 AUTH PLAIN LOGIN XOAUTH2\r\n" + }; + reader.get_mut().write_all(reply.as_bytes()).unwrap(); + } else if line == "STARTTLS\r\n" { + reader + .get_mut() + .write_all(b"454 PRIVATE-RESPONSE\r\n") + .unwrap(); + } else if line == "QUIT\r\n" { + reader.get_mut().write_all(b"221 Bye\r\n").unwrap(); + break; + } else { + panic!("sent sensitive SMTP command before TLS: {line:?}"); + } + } + transcript + }); + let transport = transport_builder( + "127.0.0.1", + port, + Credentials::new("PRIVATE-USER".into(), "PRIVATE-PASSWORD".into()), + ) + .unwrap() + .build::(); + let smtp_error = transport.send(args().message().unwrap()).await.unwrap_err(); + let smtp_error_details = format!("{smtp_error:?}"); + let error = submission_error(smtp_error); + let envelope = crate::error_envelope(&error); + assert!(!envelope.retryable); + assert!(!envelope.message.contains("PRIVATE")); + let transcript = server.join().unwrap(); + assert!( + transcript.starts_with("EHLO "), + "SMTP transcript: {transcript:?}; raw submission error: {smtp_error_details}" + ); + assert_eq!(transcript.contains("STARTTLS\r\n"), advertises_starttls); + assert!(!transcript.contains("AUTH ")); + assert!(!transcript.contains("MAIL FROM")); + assert!(!transcript.contains("RCPT TO")); + assert!(!transcript.contains("DATA")); + } + } +} diff --git a/src/mail.rs b/src/mail.rs index 9555400..2c00489 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -1,4 +1,4 @@ -//! Mail commands and the small Streamable HTTP MCP client they share. +//! Mail commands: read-only MCP operations and explicit SMTP submission. use clap::{Args, Subcommand, ValueEnum}; use reqwest::{Client, Response, Url, header::HeaderValue}; @@ -8,7 +8,7 @@ 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" + 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\n kagi mail send --from sender@example.com --to recipient@example.com --subject Hi --body \"Hello\"" )] pub struct MailCommand { #[command(subcommand)] @@ -41,6 +41,8 @@ pub enum MailSubcommand { Search(MailSearchArgs), /// Read one message or every message in a thread Read(MailReadArgs), + /// Send a plain-text email using separate SMTP environment credentials + Send(crate::mail_send::MailSendArgs), } #[derive(Debug, Args)] @@ -156,6 +158,7 @@ pub async fn run(args: MailCommand, profile: Option<&str>) -> Result<(), KagiErr MailSubcommand::Login => mail_auth::login(profile).await?, MailSubcommand::Status => mail_auth::MailConfig::load(profile)?.status(), MailSubcommand::Logout => mail_auth::logout(profile)?, + MailSubcommand::Send(send) => crate::mail_send::send(send).await?, command => { let (tool, arguments) = match command { MailSubcommand::Boxes => ("list_mailboxes", json!({})), diff --git a/src/main.rs b/src/main.rs index 1f0b626..70f6394 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,8 @@ mod local; mod mail; #[path = "mail-auth.rs"] mod mail_auth; +#[path = "mail-send.rs"] +mod mail_send; mod mcp_install; mod parser; mod quick; @@ -174,6 +176,7 @@ fn error_envelope(error: &KagiError) -> ErrorEnvelope { KagiError::Auth(message) | KagiError::MailAuth(message) => { ("authentication_error", "auth", false, message.as_str()) } + KagiError::MailSend(message) => ("mail_send_error", "smtp", 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()) @@ -210,7 +213,9 @@ fn error_envelope(error: &KagiError) -> ErrorEnvelope { } fn required_auth_for_message(message: &str) -> Option<&'static str> { - if message.contains("KAGI_MAIL_ACCESS_TOKEN") { + if message.contains("KAGI_MAIL_SMTP_USERNAME") || message.contains("KAGI_MAIL_SMTP_PASSWORD") { + Some("KAGI_MAIL_SMTP_USERNAME and KAGI_MAIL_SMTP_PASSWORD") + } else 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") @@ -230,6 +235,9 @@ fn suggested_commands_for_error( required_auth: Option<&'static str>, ) -> Vec<&'static str> { match required_auth { + Some("KAGI_MAIL_SMTP_USERNAME and KAGI_MAIL_SMTP_PASSWORD") => { + vec!["kagi mail send --help"] + } Some("KAGI_MAIL_ACCESS_TOKEN") => vec!["kagi mail status", "kagi mail login"], Some("KAGI_API_KEY") => vec![ "kagi auth status", diff --git a/src/mcp_install.rs b/src/mcp_install.rs index 4ee4200..8386b21 100644 --- a/src/mcp_install.rs +++ b/src/mcp_install.rs @@ -527,12 +527,12 @@ fn vscode_user_mcp_config_path() -> PathBuf { #[cfg(target_os = "windows")] { - return env::var_os("APPDATA") + env::var_os("APPDATA") .map(PathBuf::from) .unwrap_or_else(home_dir) .join("Code") .join("User") - .join("mcp.json"); + .join("mcp.json") } #[cfg(not(any(target_os = "macos", target_os = "windows")))] @@ -560,7 +560,7 @@ fn claude_desktop_config_path() -> Result { let appdata = env::var_os("APPDATA") .map(PathBuf::from) .ok_or_else(|| KagiError::Config("APPDATA is not set".to_string()))?; - return Ok(appdata.join("Claude").join("claude_desktop_config.json")); + Ok(appdata.join("Claude").join("claude_desktop_config.json")) } #[cfg(not(any(target_os = "macos", target_os = "windows")))] @@ -603,10 +603,10 @@ fn roo_code_config_candidates() -> Result, KagiError> { let appdata = env::var_os("APPDATA") .map(PathBuf::from) .ok_or_else(|| KagiError::Config("APPDATA is not set".to_string()))?; - return Ok(["Code", "Cursor", "Windsurf", "VSCodium"] + Ok(["Code", "Cursor", "Windsurf", "VSCodium"] .iter() .map(|name| appdata.join(name).join(&relative)) - .collect()); + .collect()) } #[cfg(not(any(target_os = "macos", target_os = "windows")))] diff --git a/src/usage.rs b/src/usage.rs index c43fbe1..57c48cc 100644 --- a/src/usage.rs +++ b/src/usage.rs @@ -440,8 +440,8 @@ fn parse_decimal_token(token: &str) -> Option { .chars() .any(|character| matches!(character, '.' | ',') && character != separator); - if (1..=3).contains(&fractional_digits) - && !(has_prior_same_separator && !has_other_separator) + if (has_other_separator || !has_prior_same_separator) + && (1..=3).contains(&fractional_digits) { compact .char_indices() diff --git a/tests/integration-cli.rs b/tests/integration-cli.rs index 1d4d1e4..05b6d6a 100644 --- a/tests/integration-cli.rs +++ b/tests/integration-cli.rs @@ -92,6 +92,8 @@ fn isolate_command_home(command: &mut Command, cwd: &Path) { .env("HOME", cwd) .env("XDG_CONFIG_HOME", cwd.join(".config")) .env("XDG_DATA_HOME", cwd.join(".local").join("share")); + #[cfg(target_os = "windows")] + command.env("APPDATA", cwd.join(".appdata")); } /// Path to the kagi config file for a sandboxed run, matching the isolated @@ -117,7 +119,12 @@ fn vscode_user_dir(cwd: &Path) -> PathBuf { .join("User") } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "windows")] + { + cwd.join(".appdata").join("Code").join("User") + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] { cwd.join(".config").join("Code").join("User") } @@ -1594,7 +1601,7 @@ fn mcp_install_dry_run_does_not_require_client_cli() { let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("MCP setup plan")); assert!(stdout.contains("Codex CLI (ok): write")); - assert!(stdout.contains(".codex/config.toml")); + assert!(stdout.replace('\\', "/").contains(".codex/config.toml")); } #[test] diff --git a/tests/mail-cli.rs b/tests/mail-cli.rs index 239b0a6..88ea236 100644 --- a/tests/mail-cli.rs +++ b/tests/mail-cli.rs @@ -8,12 +8,13 @@ use std::net::{TcpListener, TcpStream}; use std::path::Path; use std::process::{Command, Output}; use std::sync::{ - Arc, Mutex, + Arc, atomic::{AtomicBool, Ordering}, }; use std::thread::{self, JoinHandle}; use std::time::Duration; +use parking_lot::Mutex; use serde_json::{Value, json}; use tempfile::TempDir; @@ -63,12 +64,13 @@ impl MailService { while !thread_stop.load(Ordering::Relaxed) { match listener.accept() { Ok((mut stream, _)) => { + stream.set_nonblocking(false).unwrap(); 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); + thread_requests.lock().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) { @@ -113,7 +115,6 @@ impl MailService { 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") @@ -337,6 +338,8 @@ fn run(args: &[&str], directory: &Path, env: &[(&str, String)]) -> Output { "KAGI_MAIL_ENDPOINT", "KAGI_MAIL_CLIENT_ID", "KAGI_MAIL_ACCESS_TOKEN", + "KAGI_MAIL_SMTP_USERNAME", + "KAGI_MAIL_SMTP_PASSWORD", "KAGI_API_KEY", "KAGI_API_TOKEN", "KAGI_SESSION_TOKEN", @@ -600,7 +603,6 @@ fn refresh_rotates_tokens_and_preserves_other_credentials() { service .requests .lock() - .unwrap() .iter() .filter(|r| r.path == "/token") .count(), @@ -805,14 +807,7 @@ fn refresh_rejects_a_different_issuer_without_sending_tokens() { error["suggested_commands"], json!(["kagi mail status", "kagi mail login"]) ); - assert!( - !service - .requests - .lock() - .unwrap() - .iter() - .any(|r| r.path == "/token") - ); + assert!(!service.requests.lock().iter().any(|r| r.path == "/token")); assert_eq!(std::fs::read_to_string(path).unwrap(), config); } @@ -888,7 +883,6 @@ fn login_without_expiry_refreshes_before_reading_mail() { let grants: Vec<_> = service .requests .lock() - .unwrap() .iter() .filter(|request| request.path == "/token") .map(|request| form(&request.body)["grant_type"].clone()) @@ -932,3 +926,107 @@ fn malformed_config_and_insecure_urls_do_not_echo_private_values() { assert!(!String::from_utf8_lossy(&output.stderr).contains("private.example")); } } + +#[test] +fn send_requires_every_message_flag_and_rejects_unsupported_options() { + let dir = TempDir::new().unwrap(); + let fields = [ + ["--from", "sender@example.com"], + ["--to", "recipient@example.com"], + ["--subject", "PRIVATE-SUBJECT"], + ["--body", "PRIVATE-BODY"], + ]; + for omitted in 0..fields.len() { + let mut args = vec!["mail", "send"]; + for (index, field) in fields.iter().enumerate() { + if index != omitted { + args.extend(field); + } + } + let output = run(&args, dir.path(), &[]); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert!(!String::from_utf8_lossy(&output.stderr).contains("PRIVATE")); + } + for flag in ["--cc", "--bcc", "--attach", "--reply", "--smtp-password"] { + let mut args = vec!["mail", "send"]; + for field in &fields { + args.extend(field); + } + args.extend([flag, "PRIVATE"]); + let output = run(&args, dir.path(), &[]); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + } +} + +#[test] +fn send_validates_before_credentials_and_never_loads_or_persists_mcp_tokens() { + let dir = TempDir::new().unwrap(); + let service = MailService::start(Mode::default()); + // A malformed config must not be parsed by send, even with --profile. + let config = "[mail]\naccess_token = PRIVATE-SAVED-OAUTH\n"; + let config_path = dir.path().join("config.toml"); + std::fs::write(&config_path, config).unwrap(); + let args = [ + "--profile", + "unconfigured", + "mail", + "send", + "--from", + "sender@example.com", + "--to", + "recipient@example.com", + "--subject", + "PRIVATE-SUBJECT", + "--body", + "PRIVATE-BODY", + "--error-format", + "json", + ]; + for supplied in [ + None, + Some(("KAGI_MAIL_SMTP_USERNAME", "PRIVATE-USERNAME")), + Some(("KAGI_MAIL_SMTP_PASSWORD", "PRIVATE-PASSWORD")), + Some(("KAGI_MAIL_SMTP_USERNAME", " ")), + ] { + let mut env = service.env(); + env.push(("RUST_LOG", "trace".into())); + if let Some((key, value)) = supplied { + env.push((key, value.into())); + } + let output = run(&args, dir.path(), &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("fixture-access")); + let error: Value = serde_json::from_str(&stderr).unwrap(); + assert_eq!(error["code"], "missing_credentials"); + assert_eq!( + error["required_auth"], + "KAGI_MAIL_SMTP_USERNAME and KAGI_MAIL_SMTP_PASSWORD" + ); + assert_eq!( + error["suggested_commands"], + json!(["kagi mail send --help"]) + ); + assert_eq!(error["retryable"], false); + } + for (index, value, flag) in [ + (5, "PRIVATE-invalid-sender", "--from"), + (7, "PRIVATE-invalid-recipient", "--to"), + (9, "PRIVATE\r\nBcc: other@example.com", "--subject"), + ] { + let mut invalid_args = args; + invalid_args[index] = value; + let output = run(&invalid_args, dir.path(), &service.env()); + assert_eq!(output.status.code(), Some(1)); + let error: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(error["code"], "configuration_error"); + assert!(error["message"].as_str().unwrap().contains(flag)); + assert!(!error["message"].as_str().unwrap().contains("PRIVATE")); + } + assert!(service.requests.lock().is_empty()); + assert_eq!(std::fs::read_to_string(config_path).unwrap(), config); +}