From 51b8281bbd9f2c2f17a69243fe3a3d7e0780fc59 Mon Sep 17 00:00:00 2001 From: Dylan Byars Date: Thu, 16 Apr 2026 11:06:16 -0400 Subject: [PATCH 1/8] fix: sanitize attachment filenames to prevent path traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The email sender controls attachment.name, but download passed it straight into Path::new(out_dir).join(). Two escape paths: attachment.name = "../../../etc/cron.d/pwn" → Path::join resolves relative traversal beneath out_dir attachment.name = "/etc/cron.d/pwn" → Path::join silently replaces out_dir when the joined segment is absolute Any email sender could write arbitrary files when the recipient ran `fastmail-cli download`. Introduces util::sanitize_filename(raw, fallback) which: - splits on both '/' and '\\' and keeps only the final segment (covers Unix-style '../foo', absolute '/etc/foo', and Windows-style 'C:\\evil\\foo' alike) - strips NUL and other control characters - trims leading/trailing dots and whitespace - replaces Windows-reserved stems (CON, PRN, NUL, COM1-9, LPT1-9) with the fallback, preventing cross-platform footguns - caps at 200 chars while preserving the extension The download site now writes via OpenOptions::create_new(true), which uses O_EXCL on Unix and CREATE_NEW on Windows. This both refuses silent overwrite and closes a TOCTOU where an attacker pre-creates a symlink at the target path. Tests: 10 new cases covering each rejection path and the fallback. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/download.rs | 32 +++++++--- src/util.rs | 131 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 10 deletions(-) diff --git a/src/commands/download.rs b/src/commands/download.rs index 788455f..d9f3175 100644 --- a/src/commands/download.rs +++ b/src/commands/download.rs @@ -1,6 +1,10 @@ use crate::jmap::authenticated_client; use crate::models::Output; -use crate::util::{extract_text, infer_image_mime, is_image, parse_size, resize_image}; +use crate::util::{ + extract_text, infer_image_mime, is_image, parse_size, resize_image, sanitize_filename, +}; +use std::fs::OpenOptions; +use std::io::Write; use std::path::Path; pub async fn download_attachment( @@ -30,10 +34,9 @@ pub async fn download_attachment( None => continue, }; - let filename = attachment - .name - .clone() - .unwrap_or_else(|| format!("{}.bin", blob_id)); + let fallback = format!("{}.bin", blob_id); + let raw_name = attachment.name.as_deref().unwrap_or(""); + let filename = sanitize_filename(raw_name, &fallback); let content_type = attachment.content_type.clone().unwrap_or_default(); let bytes = client.download_blob(blob_id).await?; @@ -62,10 +65,9 @@ pub async fn download_attachment( None => continue, }; - let filename = attachment - .name - .clone() - .unwrap_or_else(|| format!("{}.bin", blob_id)); + let fallback = format!("{}.bin", blob_id); + let raw_name = attachment.name.as_deref().unwrap_or(""); + let filename = sanitize_filename(raw_name, &fallback); let content_type = attachment .content_type @@ -110,7 +112,17 @@ pub async fn download_attachment( }; let path = Path::new(out_dir).join(&final_filename); - std::fs::write(&path, &final_bytes)?; + // create_new(true) uses O_EXCL/CREATE_NEW — fails if the target exists, + // including through a symlink. Prevents silent overwrite and TOCTOU + // attacks where an attacker pre-creates a symlink at the target path. + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .map_err(|e| { + anyhow::anyhow!("Failed to write attachment to {}: {}", path.display(), e) + })?; + file.write_all(&final_bytes)?; downloaded.push(path.to_string_lossy().to_string()); } diff --git a/src/util.rs b/src/util.rs index bd680b3..2bfc115 100644 --- a/src/util.rs +++ b/src/util.rs @@ -253,6 +253,79 @@ pub fn resize_image( Ok((output, "image/jpeg".to_string())) } +/// Sanitize an attachment filename so it's safe to use as a path component. +/// +/// Email senders control `attachment.name`, so an unsanitized value can contain +/// `../` segments, absolute paths, NUL bytes, or Windows-reserved names. This +/// returns only the final path component, stripped of separators and control +/// characters, with Windows-reserved stems replaced. Returns `fallback` if the +/// input is empty or entirely composed of unsafe characters. +pub fn sanitize_filename(raw: &str, fallback: &str) -> String { + // Split on both forward and backslash — Windows-style names from + // cross-platform clients show up on Unix where only `/` is a separator. + let base = raw.rsplit(['/', '\\']).next().unwrap_or(""); + + let filtered: String = base + .chars() + .filter(|c| !c.is_control() && *c != '/' && *c != '\\') + .collect(); + + let trimmed = filtered.trim_matches(|c: char| c.is_whitespace() || c == '.'); + + if trimmed.is_empty() || is_windows_reserved_stem(trimmed) { + return fallback.to_string(); + } + + // Cap at 200 chars so we leave headroom below the 255-byte filename limit + // present on most filesystems, while preserving the extension. + const MAX_LEN: usize = 200; + if trimmed.len() <= MAX_LEN { + return trimmed.to_string(); + } + match Path::new(trimmed).extension().and_then(|e| e.to_str()) { + Some(ext) if ext.len() < 15 => { + let stem_len = MAX_LEN - ext.len() - 1; + let stem: String = trimmed.chars().take(stem_len).collect(); + format!("{}.{}", stem, ext) + } + _ => trimmed.chars().take(MAX_LEN).collect(), + } +} + +fn is_windows_reserved_stem(name: &str) -> bool { + let stem = Path::new(name) + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_uppercase()); + matches!( + stem.as_deref(), + Some( + "CON" + | "PRN" + | "AUX" + | "NUL" + | "COM1" + | "COM2" + | "COM3" + | "COM4" + | "COM5" + | "COM6" + | "COM7" + | "COM8" + | "COM9" + | "LPT1" + | "LPT2" + | "LPT3" + | "LPT4" + | "LPT5" + | "LPT6" + | "LPT7" + | "LPT8" + | "LPT9" + ) + ) +} + /// Load a file from disk as an attachment, inferring MIME type from extension. pub fn load_attachment(path: &str) -> anyhow::Result { let p = Path::new(path); @@ -403,4 +476,62 @@ mod tests { assert_eq!(result[0].email, "bare@example.com"); assert!(result[0].name.is_none()); } + + #[test] + fn test_sanitize_filename_strips_path_traversal() { + assert_eq!(sanitize_filename("../../etc/passwd", "fb"), "passwd"); + assert_eq!(sanitize_filename("../../../../foo.txt", "fb"), "foo.txt"); + } + + #[test] + fn test_sanitize_filename_strips_absolute_path() { + assert_eq!(sanitize_filename("/etc/passwd", "fb"), "passwd"); + assert_eq!(sanitize_filename("/tmp/evil.sh", "fb"), "evil.sh"); + } + + #[test] + fn test_sanitize_filename_rejects_nul_bytes() { + assert_eq!(sanitize_filename("foo\0bar.txt", "fb"), "foobar.txt"); + } + + #[test] + fn test_sanitize_filename_rejects_windows_reserved() { + assert_eq!(sanitize_filename("CON", "fb"), "fb"); + assert_eq!(sanitize_filename("nul.txt", "fb"), "fb"); + assert_eq!(sanitize_filename("com1", "fb"), "fb"); + assert_eq!(sanitize_filename("LPT9.log", "fb"), "fb"); + } + + #[test] + fn test_sanitize_filename_trims_dots_and_whitespace() { + assert_eq!(sanitize_filename(" .hidden.txt ", "fb"), "hidden.txt"); + assert_eq!(sanitize_filename("file.", "fb"), "file"); + assert_eq!(sanitize_filename("...", "fb"), "fb"); + } + + #[test] + fn test_sanitize_filename_empty_returns_fallback() { + assert_eq!(sanitize_filename("", "fallback.bin"), "fallback.bin"); + assert_eq!(sanitize_filename(" ", "fallback.bin"), "fallback.bin"); + } + + #[test] + fn test_sanitize_filename_preserves_normal_names() { + assert_eq!(sanitize_filename("report.pdf", "fb"), "report.pdf"); + assert_eq!(sanitize_filename("My Photo.jpg", "fb"), "My Photo.jpg"); + } + + #[test] + fn test_sanitize_filename_strips_backslash_path() { + // Windows-style separators in attachment names from cross-platform clients + assert_eq!(sanitize_filename("foo\\bar\\baz.txt", "fb"), "baz.txt"); + } + + #[test] + fn test_sanitize_filename_truncates_long_names_with_extension() { + let long = format!("{}.pdf", "a".repeat(300)); + let result = sanitize_filename(&long, "fb"); + assert!(result.len() <= 200); + assert!(result.ends_with(".pdf")); + } } From a8b365f1c9520ba852cb8635d3b5de1989cefc3b Mon Sep 17 00:00:00 2001 From: Dylan Byars Date: Thu, 16 Apr 2026 11:07:48 -0400 Subject: [PATCH 2/8] fix: URL-encode CardDAV username in addressbook paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_addressbooks() built its URL with format!("/dav/addressbooks/ user/{}/", self.username). The raw username came straight from config or the FASTMAIL_USERNAME env var and went into the URL path with no encoding. For the typical case (emails like user@domain.tld) this works — '@' and '.' are valid URL path characters. But if the username is ever misconfigured to contain '/', '?', '#', or '%', the URL is malformed and the request targets a different endpoint on the CardDAV server. Fix: add percent-encoding = "2" and run the username through utf8_percent_encode with a PATH_SEGMENT set that escapes all controls and segment delimiters. Drive-by: the adjacent contacts.sort_by() was flagged by clippy's unnecessary-sort-by lint on Rust 1.95. Rewritten as sort_by_key so CI's `cargo clippy -- -D warnings` stays green. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 349 ++++++++++++++++++++++----------------------- Cargo.toml | 1 + src/carddav/mod.rs | 21 ++- 3 files changed, 194 insertions(+), 177 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 357416e..99f2f8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -373,9 +373,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" dependencies = [ "aws-lc-sys", "zeroize", @@ -383,9 +383,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.0" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" dependencies = [ "cc", "cmake", @@ -435,17 +435,17 @@ checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bitstream-io" -version = "4.9.0" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" dependencies = [ - "core2", + "no_std_io2", ] [[package]] @@ -576,9 +576,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.57" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", "jobserver", @@ -652,9 +652,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -674,18 +674,18 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.0" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" +checksum = "3ff7a1dccbdd8b078c2bdebff47e404615151534d5043da397ec50286816f9cb" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -701,9 +701,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] @@ -818,15 +818,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -1200,6 +1191,7 @@ dependencies = [ "dirs", "image", "kreuzberg", + "percent-encoding", "reqwest", "rmcp", "roxmltree", @@ -1217,9 +1209,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fax" @@ -1478,9 +1470,9 @@ dependencies = [ [[package]] name = "gif" -version = "0.14.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" dependencies = [ "color_quant", "weezl", @@ -1560,12 +1552,19 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "hashify" -version = "0.2.7" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "149e3ea90eb5a26ad354cfe3cb7f7401b9329032d0235f2687d03a35f30e5d4c" +checksum = "dd1246c0e5493286aeb2dde35b1f4eb9c4ce00e628641210a5e553fc001a1f26" dependencies = [ + "indexmap", "proc-macro2", "quote", "syn", @@ -1600,9 +1599,9 @@ dependencies = [ [[package]] name = "html-to-markdown-rs" -version = "2.29.0" +version = "2.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9013679b8c3600142e5a8f742748c3c38c49d9fc50675dad62f8f1721090a85a" +checksum = "7ea41945a2fd834381642a000ef75b03f0030f3023f3dd3291fc5c372d3dda33" dependencies = [ "ahash", "astral-tl", @@ -1675,9 +1674,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -1690,7 +1689,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -1698,15 +1696,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -1761,12 +1758,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1774,9 +1772,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1787,9 +1785,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1801,15 +1799,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1821,15 +1819,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1913,12 +1911,12 @@ checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -1961,9 +1959,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -2093,10 +2091,12 @@ checksum = "086b08ec7a274cd60cd575ed3651ba081ee72dec0d39a6210e8adcff9efe3880" [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -2154,17 +2154,17 @@ dependencies = [ "tar", "thiserror 2.0.18", "tokio", - "toml 1.1.0+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "tracing", "whatlang", - "zip 8.4.0", + "zip 8.5.1", ] [[package]] name = "kreuzberg-pdfium-render" -version = "4.6.2" +version = "4.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8879eea989a1e68a6da78c1d11568e20147c03e401f8c2b3e6ff603221711da" +checksum = "7b3bd3fcf58d80f760e3abcafe4da1c0e2c9b61998eec6f8694baae436a30ca5" dependencies = [ "bitflags", "bytemuck", @@ -2211,9 +2211,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libfuzzer-sys" @@ -2237,14 +2237,14 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "bitflags", "libc", "plain", - "redox_syscall 0.7.3", + "redox_syscall 0.7.4", ] [[package]] @@ -2255,9 +2255,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -2317,9 +2317,9 @@ dependencies = [ [[package]] name = "lru" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" dependencies = [ "hashbrown 0.16.1", ] @@ -2425,9 +2425,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -2467,6 +2467,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "no_std_io2" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550" +dependencies = [ + "memchr", +] + [[package]] name = "nom" version = "8.0.0" @@ -2754,12 +2763,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "piston-float" version = "1.0.1" @@ -2768,9 +2771,9 @@ checksum = "ad78bf43dcf80e8f950c92b84f938a0fc7590b7f6866fbcbeca781609c115590" [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plain" @@ -2822,9 +2825,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -2872,7 +2875,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.8+spec-1.1.0", + "toml_edit 0.25.11+spec-1.1.0", ] [[package]] @@ -3039,9 +3042,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", "rand_core", @@ -3124,9 +3127,9 @@ dependencies = [ [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -3153,9 +3156,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ "bitflags", ] @@ -3348,9 +3351,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustix" @@ -3367,9 +3370,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ "aws-lc-rs", "once_cell", @@ -3430,9 +3433,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ "aws-lc-rs", "ring", @@ -3551,9 +3554,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -3620,9 +3623,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -3708,9 +3711,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simd_helpers" @@ -4023,9 +4026,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -4048,9 +4051,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" dependencies = [ "bytes", "libc", @@ -4065,9 +4068,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -4111,17 +4114,17 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.1.0", - "toml_datetime 1.1.0+spec-1.1.0", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.0", + "winnow 1.0.1", ] [[package]] @@ -4135,9 +4138,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] @@ -4158,23 +4161,23 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.8+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", - "toml_datetime 1.1.0+spec-1.1.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.0", + "winnow 1.0.1", ] [[package]] name = "toml_parser" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.0", + "winnow 1.0.1", ] [[package]] @@ -4185,9 +4188,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tower" @@ -4435,9 +4438,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "js-sys", "wasm-bindgen", @@ -4520,9 +4523,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -4533,23 +4536,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4557,9 +4556,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", @@ -4570,9 +4569,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] @@ -4613,9 +4612,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", @@ -4645,9 +4644,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] @@ -4968,9 +4967,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" dependencies = [ "memchr", ] @@ -5088,9 +5087,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wyz" @@ -5119,9 +5118,9 @@ checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -5130,9 +5129,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -5142,18 +5141,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -5162,18 +5161,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -5189,9 +5188,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -5200,9 +5199,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -5211,9 +5210,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", @@ -5236,9 +5235,9 @@ dependencies = [ [[package]] name = "zip" -version = "8.4.0" +version = "8.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7756d0206d058333667493c4014f545f4b9603c4330ccd6d9b3f86dcab59f7d9" +checksum = "dcab981e19633ebcf0b001ddd37dd802996098bc1864f90b7c5d970ce76c1d59" dependencies = [ "crc32fast", "flate2", @@ -5288,9 +5287,9 @@ dependencies = [ [[package]] name = "zune-jpeg" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] diff --git a/Cargo.toml b/Cargo.toml index 94ec58c..51c6a78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ toml = "0.8" tracing = "0.1.44" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } roxmltree = "0.21.1" +percent-encoding = "2" [dev-dependencies] tempfile = "3" diff --git a/src/carddav/mod.rs b/src/carddav/mod.rs index f79bf71..d2fe381 100644 --- a/src/carddav/mod.rs +++ b/src/carddav/mod.rs @@ -2,6 +2,7 @@ //! //! Uses raw HTTP with reqwest since CardDAV is just WebDAV with vCard. +use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode}; use reqwest::Client; use serde::{Deserialize, Serialize}; use tracing::{debug, instrument}; @@ -10,6 +11,21 @@ use crate::error::{Error, Result}; const CARDDAV_BASE: &str = "https://carddav.fastmail.com"; +// Per RFC 3986, these chars need escaping when interpolating into a URL path +// segment. `/` is the segment delimiter and must be escaped to stay in-segment. +const PATH_SEGMENT: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'<') + .add(b'>') + .add(b'?') + .add(b'`') + .add(b'{') + .add(b'}') + .add(b'/') + .add(b'%'); + /// A contact parsed from vCard #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Contact { @@ -67,7 +83,8 @@ impl CardDavClient { /// Discover address books for the user #[instrument(skip(self))] pub async fn list_addressbooks(&self) -> Result> { - let url = format!("{}/dav/addressbooks/user/{}/", CARDDAV_BASE, self.username); + let encoded_user = utf8_percent_encode(&self.username, PATH_SEGMENT); + let url = format!("{}/dav/addressbooks/user/{}/", CARDDAV_BASE, encoded_user); let body = r#" @@ -212,7 +229,7 @@ impl CardDavClient { } } - contacts.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + contacts.sort_by_key(|c| c.name.to_lowercase()); Ok(contacts) } From 45e56babc8d40409f72c507f4696ec4739c2a477 Mon Sep 17 00:00:00 2001 From: Dylan Byars Date: Thu, 16 Apr 2026 11:08:23 -0400 Subject: [PATCH 3/8] fix: atomic 0o600 token file write with symlink guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config::save() had two weaknesses: TOCTOU on permissions fs::write(&path, content)?; fs::set_permissions(&path, 0o600)?; Between the write and the chmod the file existed with the default umask mode — typically 0o644 — so another local user or process could read the token before perms were tightened. Same pattern applied to the parent directory. Symlink-follow fs::write also follows symlinks, so a hostile program with write access to the config dir could pre-create a symlink at config.toml pointing elsewhere and the token would be written through the link. Fix: write to a sibling config.toml.tmp opened via OpenOptions with .create_new(true) + .mode(0o600), then rename() atomically over the target. create_new uses O_EXCL on Unix and CREATE_NEW on Windows, so the write refuses to race with a pre-existing path (including a symlink). Before rename, symlink_metadata() is checked on the target — if it's already a symlink we refuse to overwrite it and error out loudly rather than redirect the token. Parent dir is now created with DirBuilder::mode(0o700) so the dir's mode is right from the first open(2) call; a follow-up set_permissions covers the case where the dir already existed with a wider mode. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/config.rs | 79 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 67 insertions(+), 12 deletions(-) diff --git a/src/config.rs b/src/config.rs index b45641f..39f4f55 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,8 @@ use crate::error::{Error, Result}; use serde::{Deserialize, Serialize}; use std::fs; -use std::path::PathBuf; +use std::io::Write; +use std::path::{Path, PathBuf}; #[derive(Debug, Serialize, Deserialize, Default)] pub struct Config { @@ -50,24 +51,37 @@ impl Config { pub fn save(&self) -> Result<()> { let dir = Self::config_dir()?; - fs::create_dir_all(&dir)?; + create_private_dir(&dir)?; - #[cfg(unix)] + let path = Self::config_path()?; + + // Refuse to write through a symlink — an attacker or mistaken user + // could redirect the token file elsewhere. symlink_metadata inspects + // the link itself, not its target. + if let Ok(md) = fs::symlink_metadata(&path) + && md.file_type().is_symlink() { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?; + return Err(Error::Config(format!( + "Refusing to write config: {} is a symlink", + path.display() + ))); } - let path = Self::config_path()?; let content = toml::to_string_pretty(self) .map_err(|e| Error::Config(format!("Failed to serialize config: {}", e)))?; - fs::write(&path, content)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; - } + // Write to a sibling temp file with 0o600, then rename atomically over + // the target. This closes the TOCTOU window between writing the token + // and tightening permissions. + let tmp_path = path.with_extension("toml.tmp"); + let _ = fs::remove_file(&tmp_path); + write_private_file(&tmp_path, content.as_bytes()).inspect_err(|_| { + let _ = fs::remove_file(&tmp_path); + })?; + fs::rename(&tmp_path, &path).map_err(|e| { + let _ = fs::remove_file(&tmp_path); + Error::Config(format!("Failed to install config file: {}", e)) + })?; Ok(()) } @@ -107,6 +121,47 @@ impl Config { } } +#[cfg(unix)] +fn create_private_dir(dir: &Path) -> Result<()> { + use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; + // DirBuilder::mode applies to newly-created directories only. Following up + // with set_permissions tightens the mode if the directory already existed. + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(dir)?; + fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +#[cfg(not(unix))] +fn create_private_dir(dir: &Path) -> Result<()> { + fs::create_dir_all(dir)?; + Ok(()) +} + +#[cfg(unix)] +fn write_private_file(path: &Path, contents: &[u8]) -> Result<()> { + use std::os::unix::fs::OpenOptionsExt; + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path)?; + file.write_all(contents)?; + Ok(()) +} + +#[cfg(not(unix))] +fn write_private_file(path: &Path, contents: &[u8]) -> Result<()> { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path)?; + file.write_all(contents)?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; From cfdfdd143da447a2441b60f378a42f50cfd51fe0 Mon Sep 17 00:00:00 2001 From: Dylan Byars Date: Thu, 16 Apr 2026 11:08:34 -0400 Subject: [PATCH 4/8] feat: read API token from stdin when `auth` is called without one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the `auth` subcommand took the token as a positional argument: fastmail-cli auth fmu1-your-token-here which exposed the token to: - `ps auxw` (visible to every local user) - shell history files (~/.zsh_history, ~/.bash_history) - the process environment inherited by child processes - any terminal-multiplexer log The token is now Option. When omitted the command reads a single line from stdin (trimmed). On a TTY it prints a prompt to stderr; when stdin is piped (CI, scripts, paste into a here-string) it reads silently. # interactive — prompts on stderr, line read from tty fastmail-cli auth # script / piped — no prompt, just reads echo "$FASTMAIL_TOKEN" | fastmail-cli auth The positional form still works for backward-compatibility, but the help text steers users toward stdin. README/CHANGELOG updated in a follow-up commit. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/auth.rs | 19 +++++++++++++++++++ src/main.rs | 16 +++++++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 7387712..3c8312d 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -1,6 +1,7 @@ use crate::config::Config; use crate::jmap::JmapClient; use crate::models::Output; +use std::io::{self, BufRead, IsTerminal, Write}; pub async fn auth(token: &str) -> anyhow::Result<()> { let mut client = JmapClient::new(token.to_string()); @@ -14,3 +15,21 @@ pub async fn auth(token: &str) -> anyhow::Result<()> { Ok(()) } + +/// Read a token from stdin — used when the user runs `auth` without a positional arg. +/// Keeping the token off the command line avoids exposing it in `ps`, shell history, +/// and the process environment visible to other local users. +pub fn read_token_from_stdin() -> anyhow::Result { + let stdin = io::stdin(); + if stdin.is_terminal() { + eprint!("Paste your Fastmail API token and press Enter: "); + io::stderr().flush().ok(); + } + let mut line = String::new(); + stdin.lock().read_line(&mut line)?; + let token = line.trim().to_string(); + if token.is_empty() { + anyhow::bail!("No token provided on stdin"); + } + Ok(token) +} diff --git a/src/main.rs b/src/main.rs index fe58563..0db9748 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,8 +50,9 @@ struct Cli { enum Commands { /// Authenticate with Fastmail API token Auth { - /// API token from Fastmail settings - token: String, + /// API token from Fastmail settings. If omitted, the token is read from + /// stdin so it doesn't appear in `ps`, shell history, or the environment. + token: Option, }, /// List resources @@ -420,7 +421,16 @@ async fn main() { let cli = Cli::parse(); let result = match cli.command { - Commands::Auth { token } => commands::auth(&token).await, + Commands::Auth { token } => { + let resolved = match token { + Some(t) => Ok(t), + None => commands::read_token_from_stdin(), + }; + match resolved { + Ok(t) => commands::auth(&t).await, + Err(e) => Err(e), + } + } Commands::List(cmd) => match cmd { ListCommands::Mailboxes => commands::list_mailboxes().await, From a84576a27f7b6177a69f9f6078dfe94dbecb9658 Mon Sep 17 00:00:00 2001 From: Dylan Byars Date: Thu, 16 Apr 2026 11:08:48 -0400 Subject: [PATCH 5/8] refactor: single-pass URL template + tighten InvalidToken variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small hardenings in the JMAP client: URL template substitution (M1) download_blob/upload_blob built URLs by chaining str::replace: .replace("{accountId}", account_id) .replace("{blobId}", blob_id) .replace("{name}", "attachment") .replace("{type}", "application/octet-stream") If any substituted value ever contained another placeholder — e.g. a blob_id literally spelled "{name}" — it would bleed into the next step and produce a surprising URL. Today all template inputs come from trusted sources (Fastmail session data or hardcoded strings), so this is defense-in-depth rather than a live bug. But it's the kind of bug that's painful to notice if the trust boundary ever shifts. Replaced with apply_url_template(tmpl, &[(key, value), ...]), a single-pass helper that never re-scans substituted content. Unknown placeholders are preserved literally. 5 unit tests cover: basic substitution, no-cascade, unknown placeholders, no-op, unterminated braces. InvalidToken variant (M4) Error::InvalidToken(String) invited a future contributor to stuff the token itself into the variant for "better debug output" — which would then show up in tracing output and the Output::error JSON. Narrowed to InvalidToken(&'static str) so construction sites can only pass compile-time literals. All four existing callsites already used literals, so this is a type-level constraint that prevents regression, not a behaviour change. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/error.rs | 7 +++- src/jmap/mod.rs | 97 +++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 92 insertions(+), 12 deletions(-) diff --git a/src/error.rs b/src/error.rs index d623f4e..351175d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,8 +5,13 @@ pub enum Error { #[error("Authentication required. Run `fastmail-cli auth ` first.")] NotAuthenticated, + /// Authentication was rejected by the server. + /// + /// The inner value is a `&'static str` by design — using a static literal + /// ensures no call site can accidentally pass the token itself or another + /// secret into this variant where it would then surface in error output. #[error("Invalid API token: {0}")] - InvalidToken(String), + InvalidToken(&'static str), #[error("HTTP error: {0}")] Http(#[from] reqwest::Error), diff --git a/src/jmap/mod.rs b/src/jmap/mod.rs index 5178666..bf8195b 100644 --- a/src/jmap/mod.rs +++ b/src/jmap/mod.rs @@ -247,6 +247,41 @@ struct JmapResponse { method_responses: Vec, } +/// Substitute `{placeholder}` tokens in a URL template in a single pass. +/// +/// Unlike chaining `str::replace`, this never re-scans an already-substituted +/// value, so a variable value that contains another template marker cannot +/// bleed into a later replacement. +fn apply_url_template(tmpl: &str, vars: &[(&str, &str)]) -> String { + let mut result = String::with_capacity(tmpl.len()); + let mut rest = tmpl; + while let Some(open) = rest.find('{') { + result.push_str(&rest[..open]); + let after_open = &rest[open + 1..]; + if let Some(close) = after_open.find('}') { + let key = &after_open[..close]; + match vars.iter().find(|(k, _)| *k == key) { + Some((_, v)) => result.push_str(v), + None => { + // Unknown placeholder — preserve literally so a downstream + // system that recognises it still can. + result.push('{'); + result.push_str(key); + result.push('}'); + } + } + rest = &after_open[close + 1..]; + } else { + // Unterminated — emit the remainder verbatim and stop. + result.push('{'); + result.push_str(after_open); + return result; + } + } + result.push_str(rest); + result +} + fn pick_identity(identities: Vec, from: Option<&str>) -> Result { match from { Some(email) => identities @@ -284,7 +319,7 @@ impl JmapClient { .await?; match resp.status().as_u16() { - 401 => return Err(Error::InvalidToken("Authentication failed".into())), + 401 => return Err(Error::InvalidToken("Authentication failed")), 429 => return Err(Error::RateLimited), 500..=599 => return Err(Error::Server(format!("Server error: {}", resp.status()))), _ => {} @@ -342,7 +377,7 @@ impl JmapClient { .await?; match resp.status().as_u16() { - 401 => return Err(Error::InvalidToken("Token expired or invalid".into())), + 401 => return Err(Error::InvalidToken("Token expired or invalid")), 429 => return Err(Error::RateLimited), 500..=599 => return Err(Error::Server(format!("Server error: {}", resp.status()))), _ => {} @@ -951,12 +986,18 @@ impl JmapClient { let session = self.session()?; // downloadUrl template: https://api.fastmail.com/jmap/download/{accountId}/{blobId}/{name}?accept={type} - let url = session - .download_url - .replace("{accountId}", account_id) - .replace("{blobId}", blob_id) - .replace("{name}", "attachment") - .replace("{type}", "application/octet-stream"); + // + // Single-pass substitution — chained .replace() calls could recursively + // replace a value that happened to contain another template marker. + let url = apply_url_template( + &session.download_url, + &[ + ("accountId", account_id), + ("blobId", blob_id), + ("name", "attachment"), + ("type", "application/octet-stream"), + ], + ); debug!(url = %url, "Downloading blob"); let resp = self @@ -967,7 +1008,7 @@ impl JmapClient { .await?; match resp.status().as_u16() { - 401 => return Err(Error::InvalidToken("Token expired or invalid".into())), + 401 => return Err(Error::InvalidToken("Token expired or invalid")), 404 => return Err(Error::Config(format!("Blob not found: {}", blob_id))), 429 => return Err(Error::RateLimited), 500..=599 => return Err(Error::Server(format!("Server error: {}", resp.status()))), @@ -984,7 +1025,7 @@ impl JmapClient { let account_id = self.account_id()?; let session = self.session()?; - let url = session.upload_url.replace("{accountId}", account_id); + let url = apply_url_template(&session.upload_url, &[("accountId", account_id)]); debug!(url = %url, content_type = %content_type, size = data.len(), "Uploading blob"); let resp = self @@ -998,7 +1039,7 @@ impl JmapClient { match resp.status().as_u16() { 200..=299 => {} - 401 => return Err(Error::InvalidToken("Token expired or invalid".into())), + 401 => return Err(Error::InvalidToken("Token expired or invalid")), 429 => return Err(Error::RateLimited), 500..=599 => return Err(Error::Server(format!("Server error: {}", resp.status()))), code => { @@ -1381,6 +1422,40 @@ mod tests { } } + #[test] + fn test_apply_url_template_basic() { + let result = apply_url_template( + "https://api.example.com/{a}/{b}", + &[("a", "hello"), ("b", "world")], + ); + assert_eq!(result, "https://api.example.com/hello/world"); + } + + #[test] + fn test_apply_url_template_no_cascade() { + // A value that contains another template marker must not be re-substituted. + let result = apply_url_template("https://x/{a}/{b}", &[("a", "{b}"), ("b", "LEAKED")]); + assert_eq!(result, "https://x/{b}/LEAKED"); + } + + #[test] + fn test_apply_url_template_unknown_placeholder_preserved() { + let result = apply_url_template("/{known}/{other}", &[("known", "X")]); + assert_eq!(result, "/X/{other}"); + } + + #[test] + fn test_apply_url_template_no_placeholders() { + let result = apply_url_template("https://api.example.com/v1", &[]); + assert_eq!(result, "https://api.example.com/v1"); + } + + #[test] + fn test_apply_url_template_unterminated_brace() { + let result = apply_url_template("/path/{unterminated", &[]); + assert_eq!(result, "/path/{unterminated"); + } + #[test] fn test_require_capability_succeeds_when_present() { let mut client = JmapClient::new("test-token".to_string()); From 27b051caf30cc0831b339a1a8cdf832424bee930 Mon Sep 17 00:00:00 2001 From: Dylan Byars Date: Thu, 16 Apr 2026 11:09:09 -0400 Subject: [PATCH 6/8] fix: use random one-shot nonces for MCP compose confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP sendEmail / replyToEmail / forwardEmail mutations enforce a PREVIEW -> CONFIRM flow to stop an LLM from sending without showing the user what it's about to send. That confirmation was a DefaultHasher-based hash of the compose params: token = hash(to, subject, body) which is a *signature*, not a *nonce*: calling CONFIRM with the same params always produces the same token, so the "confirmation" check collapsed to "does the caller know the params" — trivially satisfied by anyone calling the mutation at all. The check it was meant to enforce was "the caller previously called PREVIEW for these exact params". That requires server-side state tied to the PREVIEW call. Introduce NonceStore (tokio::sync::Mutex>) injected into the GraphQL schema as context data: PREVIEW - generate a uuid::Uuid::new_v4() as the nonce - store nonce -> fingerprint(params) in NonceStore - return the nonce as confirmation_token CONFIRM / DRAFT - look up and REMOVE the nonce from NonceStore (one-shot) - compare stored fingerprint vs fingerprint(submitted params) - reject on missing nonce (PREVIEW not called, or re-used) - reject on fingerprint mismatch (params tampered between PREVIEW and CONFIRM, which a prompt-injected agent might try) This still isn't a proof that a human approved — an LLM can call PREVIEW and immediately CONFIRM without human intervention — but it is now a genuine state-transition check rather than a tautology, and it makes param-tampering attacks observable. Adds uuid = "1" with the "v4" feature as a direct dep. Cargo.lock was already pulling uuid in transitively via kreuzberg -> cfb, so no new crate enters the build, only a new direct reference and the v4 feature. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 2 ++ Cargo.toml | 1 + src/mcp/graphql/mod.rs | 4 +++- src/mcp/graphql/mutation.rs | 43 ++++++++++++++++++++---------------- src/mcp/graphql/types.rs | 44 +++++++++++++++++++++++++++++++++++-- 5 files changed, 72 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 99f2f8f..ef0c7da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1204,6 +1204,7 @@ dependencies = [ "toml 0.8.23", "tracing", "tracing-subscriber", + "uuid", "wiremock", ] @@ -4442,6 +4443,7 @@ version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index 51c6a78..28d729d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ tracing = "0.1.44" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } roxmltree = "0.21.1" percent-encoding = "2" +uuid = { version = "1", features = ["v4"] } [dev-dependencies] tempfile = "3" diff --git a/src/mcp/graphql/mod.rs b/src/mcp/graphql/mod.rs index ac12296..9943603 100644 --- a/src/mcp/graphql/mod.rs +++ b/src/mcp/graphql/mod.rs @@ -17,9 +17,11 @@ use query::QueryRoot; pub type FastmailSchema = Schema; -/// Build the GraphQL schema with the JMAP client injected as context data. +/// Build the GraphQL schema with the JMAP client and the preview-nonce store +/// injected as context data. pub fn build_schema(client: Mutex) -> FastmailSchema { Schema::build(QueryRoot, MutationRoot, async_graphql::EmptySubscription) .data(client) + .data(types::NonceStore::default()) .finish() } diff --git a/src/mcp/graphql/mutation.rs b/src/mcp/graphql/mutation.rs index bbfca7c..90126c7 100644 --- a/src/mcp/graphql/mutation.rs +++ b/src/mcp/graphql/mutation.rs @@ -33,9 +33,11 @@ impl MutationRoot { let to_addrs = parse_addresses(&to); let cc_addrs = cc.as_deref().map(parse_addresses).unwrap_or_default(); let bcc_addrs = bcc.as_deref().map(parse_addresses).unwrap_or_default(); - let token = super::types::confirmation_token(&[&to, &subject, &body]); + let nonce_store = ctx.data::()?; + let params = [to.as_str(), subject.as_str(), body.as_str()]; if matches!(action, SendAction::Preview) { + let nonce = super::types::issue_nonce(nonce_store, ¶ms).await; let mut preview = format_send_preview(&to_addrs, &cc_addrs, &bcc_addrs, &subject, &body); if html_body.is_some() { @@ -45,21 +47,20 @@ impl MutationRoot { success: true, email_id: None, preview: Some(preview), - confirmation_token: Some(token), + confirmation_token: Some(nonce), error: None, }); } - if confirmation_token.as_deref() != Some(&token) { + if let Err(msg) = + super::types::consume_nonce(nonce_store, confirmation_token.as_deref(), ¶ms).await + { return Ok(GqlComposeResult { success: false, email_id: None, preview: None, confirmation_token: None, - error: Some( - "Missing or invalid confirmation_token. Use action=PREVIEW first to get the token." - .to_string(), - ), + error: Some(msg.to_string()), }); } @@ -119,7 +120,8 @@ impl MutationRoot { #[graphql(desc = "Token from PREVIEW response — required for CONFIRM/DRAFT")] confirmation_token: Option, ) -> Result { - let token = super::types::confirmation_token(&[&email_id, &body]); + let nonce_store = ctx.data::()?; + let params = [email_id.as_str(), body.as_str()]; let client = ctx.data::>()?; let mut client = client.lock().await; @@ -141,6 +143,7 @@ impl MutationRoot { let to_addrs: Vec = original.from.clone().unwrap_or_default(); if matches!(action, SendAction::Preview) { + let nonce = super::types::issue_nonce(nonce_store, ¶ms).await; let in_reply_to = original .message_id .as_ref() @@ -171,20 +174,20 @@ impl MutationRoot { success: true, email_id: None, preview: Some(preview), - confirmation_token: Some(token), + confirmation_token: Some(nonce), error: None, }); } - if confirmation_token.as_deref() != Some(&token) { + if let Err(msg) = + super::types::consume_nonce(nonce_store, confirmation_token.as_deref(), ¶ms).await + { return Ok(GqlComposeResult { success: false, email_id: None, preview: None, confirmation_token: None, - error: Some( - "Missing or invalid confirmation_token. Use action=PREVIEW first.".to_string(), - ), + error: Some(msg.to_string()), }); } @@ -241,7 +244,8 @@ impl MutationRoot { confirmation_token: Option, ) -> Result { let body_str = body.as_deref().unwrap_or(""); - let token = super::types::confirmation_token(&[&email_id, &to, body_str]); + let nonce_store = ctx.data::()?; + let params = [email_id.as_str(), to.as_str(), body_str]; let client = ctx.data::>()?; let mut client = client.lock().await; @@ -261,6 +265,7 @@ impl MutationRoot { }; if matches!(action, SendAction::Preview) { + let nonce = super::types::issue_nonce(nonce_store, ¶ms).await; let original_body = original.text_content().unwrap_or(""); let sender = format_addrs(&original.from.clone().unwrap_or_default()); @@ -292,20 +297,20 @@ impl MutationRoot { success: true, email_id: None, preview: Some(preview), - confirmation_token: Some(token), + confirmation_token: Some(nonce), error: None, }); } - if confirmation_token.as_deref() != Some(&token) { + if let Err(msg) = + super::types::consume_nonce(nonce_store, confirmation_token.as_deref(), ¶ms).await + { return Ok(GqlComposeResult { success: false, email_id: None, preview: None, confirmation_token: None, - error: Some( - "Missing or invalid confirmation_token. Use action=PREVIEW first.".to_string(), - ), + error: Some(msg.to_string()), }); } diff --git a/src/mcp/graphql/types.rs b/src/mcp/graphql/types.rs index a459d3a..fc8d065 100644 --- a/src/mcp/graphql/types.rs +++ b/src/mcp/graphql/types.rs @@ -478,8 +478,19 @@ pub struct GqlComposeResult { pub error: Option, } -/// Generate a confirmation token from email parameters (stateless preview guard) -pub fn confirmation_token(parts: &[&str]) -> String { +/// Server-side store of issued but unused confirmation nonces. +/// +/// PREVIEW issues a random UUID paired with a fingerprint of the compose +/// params. CONFIRM/DRAFT must supply a nonce that's still in the store and +/// whose stored fingerprint matches the current params — this prevents +/// skipping PREVIEW and prevents reusing a nonce for different params. +pub type NonceStore = tokio::sync::Mutex>; + +/// Fingerprint the compose params so we can detect param tampering between +/// PREVIEW and CONFIRM. This is a non-cryptographic hash — it only needs to +/// detect accidental drift, not defeat an attacker who already controls the +/// process. +pub fn params_fingerprint(parts: &[&str]) -> String { use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); for part in parts { @@ -488,6 +499,35 @@ pub fn confirmation_token(parts: &[&str]) -> String { format!("{:016x}", hasher.finish()) } +/// Issue a new one-shot confirmation nonce for the given params. +pub async fn issue_nonce(store: &NonceStore, parts: &[&str]) -> String { + let nonce = uuid::Uuid::new_v4().to_string(); + let fingerprint = params_fingerprint(parts); + store.lock().await.insert(nonce.clone(), fingerprint); + nonce +} + +/// Consume a nonce, returning Ok(()) if it was issued for the given params. +/// The nonce is always removed on consumption, even on mismatch, so a bad +/// CONFIRM forces the caller back to PREVIEW. +pub async fn consume_nonce( + store: &NonceStore, + nonce: Option<&str>, + parts: &[&str], +) -> std::result::Result<(), &'static str> { + let nonce = + nonce.ok_or("Missing confirmation_token. Use action=PREVIEW first to obtain one.")?; + let stored = store + .lock() + .await + .remove(nonce) + .ok_or("Invalid or already-used confirmation_token. Re-run PREVIEW.")?; + if stored != params_fingerprint(parts) { + return Err("Params changed between PREVIEW and CONFIRM. Re-run PREVIEW."); + } + Ok(()) +} + #[derive(SimpleObject)] #[graphql(name = "Status")] pub struct GqlStatus { From e235ac0256445e71416ff99cb621ee92988934dd Mon Sep 17 00:00:00 2001 From: Dylan Byars Date: Thu, 16 Apr 2026 11:10:47 -0400 Subject: [PATCH 7/8] docs: note stdin auth in README and log security fixes in CHANGELOG - README: expand the Authentication section to cover the new stdin flow (interactive prompt + pipe form), keep the positional form documented for backward compat, and mention the atomic-write / symlink-refuse behaviour so users aren't surprised. - CHANGELOG: new [Unreleased] block with a Security subsection listing each finding (C1, C2, H1, H2, H3, M1, M3, M4) in the same voice as prior entries, plus a Changed note that `auth` arg is now Option. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 17 +++++++++++++++++ README.md | 12 +++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a979c..179d000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [Unreleased] + +### Security + +- **Attachment path traversal (C1)** — `fastmail-cli download` wrote attachments to `Path::new(out_dir).join(attachment.name)`, where `attachment.name` is chosen by the email sender. A name of `../../etc/cron.d/pwn` escaped the output directory via relative traversal; an absolute name like `/etc/cron.d/pwn` replaced the base path outright because `Path::join` discards the base when the joined segment is absolute. A malicious email could write arbitrary files on any recipient who ran the `download` subcommand. Filenames are now run through `util::sanitize_filename`, which strips path separators, NUL/control bytes, and Windows-reserved stems (CON/PRN/NUL/COM1-9/LPT1-9). Writes use `OpenOptions::create_new(true)`, so silent overwrites and symlink-pre-placement attacks at the target path are also refused. +- **CardDAV URL injection (C2)** — `list_addressbooks()` interpolated the raw username into `/dav/addressbooks/user/{}/` without percent-encoding. Misconfigured usernames containing `/`, `?`, `#`, or `%` produced malformed URLs that could target a different CardDAV endpoint. Now percent-encoded with an explicit path-segment set. +- **Token file TOCTOU (H1)** — `Config::save()` ran `fs::write(path, token)` followed by `fs::set_permissions(0o600)`, leaving a window where the token file was readable under the default umask. The write is now atomic: the token is written to a sibling `.tmp` file opened with `OpenOptions::mode(0o600).create_new(true)`, then `rename()`d over the target. The parent directory is created with `DirBuilder::mode(0o700)`. +- **Symlinked config path (H2)** — `fs::write` followed symlinks at the config file path. A hostile program with write access to `~/.config/fastmail-cli/` could pre-place a symlink redirecting the token write. `save()` now checks `symlink_metadata()` and refuses to write if the target is a symlink. +- **Token in argv (H3)** — `fastmail-cli auth YOUR_TOKEN` exposed the token to `ps`, shell history, and the process environment. The token argument is now optional; when omitted it is read from stdin (with a TTY prompt). The positional form is retained for backward compatibility. +- **URL template substitution bleed (M1)** — `download_blob` and `upload_blob` built URLs by chaining `str::replace`, which would recursively substitute a template-like value into a later placeholder. Replaced with a single-pass `apply_url_template` helper. Defense-in-depth — no live bug, all current inputs are trusted — but it future-proofs the code against trust-boundary changes. +- **Stateless compose confirmation (M3)** — The MCP `sendEmail` / `replyToEmail` / `forwardEmail` PREVIEW→CONFIRM flow used a `DefaultHasher` of the params as the confirmation token, which was a signature rather than a nonce — any caller who knew the params could produce a valid token without ever calling PREVIEW. Replaced with a random UUIDv4 nonce issued on PREVIEW, stored server-side, and consumed one-shot on CONFIRM/DRAFT with a params-fingerprint check so tampering between PREVIEW and CONFIRM is detected. +- **`InvalidToken` variant footgun (M4)** — The variant held `String`, inviting future contributors to embed the actual token in the error payload for "better debug output". Narrowed to `&'static str` so only compile-time literals can be passed. + +### Changed + +- `auth` CLI arg is now `Option` (backward compatible — the positional form still works). + ## [2.1.0] - 2026-04-11 ### Added diff --git a/README.md b/README.md index 40740ec..2ffa0f8 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,19 @@ cargo install --git https://github.com/radiosilence/fastmail-cli ### Authentication 1. Generate an API token at [Fastmail Settings > Privacy & Security > Integrations > API tokens](https://app.fastmail.com/settings/security/tokens) -2. Auth with the CLI: +2. Auth with the CLI — the token is read from stdin so it stays out of shell history, `ps`, and the process environment: ```bash -fastmail-cli auth YOUR_TOKEN +# interactive — paste the token at the prompt +fastmail-cli auth + +# non-interactive — pipe from a password manager, file, or env var +echo "$FASTMAIL_TOKEN" | fastmail-cli auth ``` -Token is stored in `~/.config/fastmail-cli/config.toml` with 0600 permissions. +The positional form `fastmail-cli auth YOUR_TOKEN` still works for backward compatibility, but the stdin form is preferred. + +Token is stored in `~/.config/fastmail-cli/config.toml` with `0600` permissions (directory `0700`). The file is written atomically via rename, and the path is refused if it's a symlink. ### Configuration From fc7cd9fdee01da1c2eaba034a5b6216f3b846b76 Mon Sep 17 00:00:00 2001 From: Dylan Byars Date: Thu, 16 Apr 2026 11:22:22 -0400 Subject: [PATCH 8/8] fix: reply-all preview divergence + duplicate-recipient risk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP replyToEmail mutation's PREVIEW path built recipients as: let to_addrs: Vec = original.from.clone().unwrap_or_default(); and used the raw user-supplied `cc` unchanged, never consulting reply_all. Calling PREVIEW with `all: true` would therefore display only the original sender in To and whatever the user explicitly passed as CC — not what the send path would actually produce. The send path in JmapClient::reply_email correctly expanded reply-all, so PREVIEW and CONFIRM diverged for the same arguments. Two knock-on effects: 1. Preview lies — the user cannot see the true recipient list before confirming a reply-all. 2. Duplicate-send — if a user tried to work around (1) by passing the missing recipients as explicit `cc`, those addresses would *also* be expanded into To by the send path at CONFIRM time, producing duplicate delivery. Observed live: original email `To: [Sher, Dylan, Anne, Leon]`, reply-all preview showed `To: Paul, CC: (none)`, "fixing" by adding Sher/Anne/Leon via cc would have sent `To: Paul,Sher,Anne,Leon` AND `Cc: Sher,Anne,Leon`. Fix: extracted `jmap::expand_reply_recipients` as a pure function used by both the preview path (via a new `resolve_my_email` helper on JmapClient so preview can filter out the sending identity without re-implementing the lookup) and the send path in reply_email. The helper also deduplicates by lowercase email and strips from CC anything already in To — closing the duplicate-send window regardless of how the two paths evolve. Tests: 9 new unit tests covering plain reply (no expansion), reply-all basic expansion, me-filtering (case-insensitive), dedup of overlapping user CC and reply-all-expanded To (the exact bug report scenario), dedup of duplicates in original recipients, preview-without-identity falling back to dedup-only, and recipient ordering preservation. 78 total passing, fmt/clippy clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 4 + src/commands/reply.rs | 14 +- src/jmap/mod.rs | 257 +++++++++++++++++++++++++++++++----- src/mcp/graphql/mutation.rs | 16 ++- 4 files changed, 253 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 179d000..9a57262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **Reply-all preview divergence (B1)** — The MCP `replyToEmail` mutation's PREVIEW path never consulted `reply_all` when building recipients, so calling it with `all: true` showed only the original sender in `To` and whatever the user explicitly passed as `cc`. Meanwhile the send path in `reply_email` expanded reply-all correctly. This had two knock-on effects: (1) the preview lied about who would actually receive the email, and (2) a user who "fixed" the under-reported preview by passing missing recipients as explicit `cc` could produce a duplicate-send, because those same addresses would also be expanded into `To` by the send path at CONFIRM time. Extracted `jmap::expand_reply_recipients` as a shared pure function used by both preview and send; the function now also deduplicates by lowercase email and strips from `Cc` anything already present in `To`, closing the duplicate-send window regardless of how the paths evolve. 9 unit tests cover reply-all expansion, me-filtering (case-insensitive), dedup, and the exact overlap scenario from the bug report. + ### Security - **Attachment path traversal (C1)** — `fastmail-cli download` wrote attachments to `Path::new(out_dir).join(attachment.name)`, where `attachment.name` is chosen by the email sender. A name of `../../etc/cron.d/pwn` escaped the output directory via relative traversal; an absolute name like `/etc/cron.d/pwn` replaced the base path outright because `Path::join` discards the base when the joined segment is absolute. A malicious email could write arbitrary files on any recipient who ran the `download` subcommand. Filenames are now run through `util::sanitize_filename`, which strips path separators, NUL/control bytes, and Windows-reserved stems (CON/PRN/NUL/COM1-9/LPT1-9). Writes use `OpenOptions::create_new(true)`, so silent overwrites and symlink-pre-placement attacks at the target path are also refused. diff --git a/src/commands/reply.rs b/src/commands/reply.rs index c060d9b..79380f0 100644 --- a/src/commands/reply.rs +++ b/src/commands/reply.rs @@ -1,4 +1,4 @@ -use crate::jmap::{ComposeParams, authenticated_client}; +use crate::jmap::{ComposeParams, authenticated_client, expand_reply_recipients}; use crate::models::Output; pub async fn reply( @@ -12,8 +12,18 @@ pub async fn reply( let original = client.get_email(email_id).await?; + // Expand reply-all and filter the sending identity on the caller side, + // so this code path and the MCP preview path use the exact same helper. + let my_email = client.resolve_my_email(params.from).await; + let (to_addrs, cc_addrs) = + expand_reply_recipients(&original, reply_all, my_email.as_deref(), params.cc); + let params = ComposeParams { + cc: cc_addrs, + ..params + }; + let new_email_id = client - .reply_email(&original, body, reply_all, params) + .reply_email(&original, body, to_addrs, params) .await?; #[derive(serde::Serialize)] diff --git a/src/jmap/mod.rs b/src/jmap/mod.rs index bf8195b..f8e4ed3 100644 --- a/src/jmap/mod.rs +++ b/src/jmap/mod.rs @@ -292,6 +292,69 @@ fn pick_identity(identities: Vec, from: Option<&str>) -> Result, + extra_cc: Vec, +) -> (Vec, Vec) { + let me_lower = my_email.map(str::to_lowercase); + let is_me = |addr: &EmailAddress| -> bool { + me_lower + .as_deref() + .is_some_and(|m| addr.email.eq_ignore_ascii_case(m)) + }; + + let mut to_addrs: Vec = original.from.clone().unwrap_or_default(); + if reply_all && let Some(ref orig_to) = original.to { + for addr in orig_to { + if !is_me(addr) { + to_addrs.push(addr.clone()); + } + } + } + + let mut cc_addrs = extra_cc; + if reply_all && let Some(ref orig_cc) = original.cc { + for addr in orig_cc { + if !is_me(addr) { + cc_addrs.push(addr.clone()); + } + } + } + + dedup_by_email(&mut to_addrs); + let to_lower: std::collections::HashSet = + to_addrs.iter().map(|a| a.email.to_lowercase()).collect(); + cc_addrs.retain(|c| !to_lower.contains(&c.email.to_lowercase())); + dedup_by_email(&mut cc_addrs); + + (to_addrs, cc_addrs) +} + +fn dedup_by_email(addrs: &mut Vec) { + let mut seen = std::collections::HashSet::::new(); + addrs.retain(|a| seen.insert(a.email.to_lowercase())); +} + impl JmapClient { pub fn new(token: String) -> Self { let client = Client::builder() @@ -757,6 +820,14 @@ impl JmapClient { pick_identity(identities, from) } + /// Return the email address that would be used as the sender for a reply/ + /// send/forward — i.e. the resolved identity's email. Returns `None` if + /// identity resolution fails, so callers (notably the MCP preview path) + /// can still produce a useful preview without erroring out. + pub async fn resolve_my_email(&self, from: Option<&str>) -> Option { + self.resolve_identity(from).await.ok().map(|i| i.email) + } + async fn prepare_compose(&mut self, from: Option<&str>, draft: bool) -> Result { if !draft { self.require_capability("urn:ietf:params:jmap:submission", "Email sending")?; @@ -1055,47 +1126,25 @@ impl JmapClient { .ok_or_else(|| Error::Server("Upload response missing blobId".into())) } - /// Send a reply to an existing email with proper threading headers + /// Send a reply to an existing email with proper threading headers. + /// + /// The caller is responsible for computing `to` and `params.cc` — usually + /// by calling [`expand_reply_recipients`] after resolving the sending + /// identity with [`JmapClient::resolve_my_email`]. Keeping the expansion + /// on the caller side means the MCP preview path and the send path use + /// exactly the same recipient lists, so the preview cannot under-report + /// or diverge from what will actually be sent. #[instrument(skip(self, body, params))] pub async fn reply_email( &mut self, original: &Email, body: &str, - reply_all: bool, + to: Vec, params: ComposeParams<'_>, ) -> Result { let ctx = self.prepare_compose(params.from, params.draft).await?; - - let my_email = ctx - .identity - .as_ref() - .map(|i| i.email.to_lowercase()) - .or_else(|| params.from.map(|f| f.to_lowercase())) - .unwrap_or_default(); - - // Build To: reply to sender, or if reply_all, include original recipients - let mut to_addrs: Vec = original.from.clone().unwrap_or_default(); - - if reply_all { - // Add original To recipients (except ourselves) - if let Some(ref orig_to) = original.to { - for addr in orig_to { - if my_email.is_empty() || addr.email.to_lowercase() != my_email { - to_addrs.push(addr.clone()); - } - } - } - } - - // Build CC: include original CC recipients (if reply_all) plus any new CC - let mut cc_addrs = params.cc; - if reply_all && let Some(ref orig_cc) = original.cc { - for addr in orig_cc { - if my_email.is_empty() || addr.email.to_lowercase() != my_email { - cc_addrs.push(addr.clone()); - } - } - } + let to_addrs = to; + let cc_addrs = params.cc; // Build subject with Re: prefix if not already present let subject = if original @@ -1527,6 +1576,148 @@ mod tests { } } + fn addr(email: &str) -> EmailAddress { + EmailAddress { + name: None, + email: email.to_string(), + } + } + + fn reply_fixture(from: Vec<&str>, to: Vec<&str>, cc: Vec<&str>) -> Email { + let mut email = Email { + id: "test".into(), + blob_id: None, + thread_id: None, + mailbox_ids: HashMap::new(), + keywords: HashMap::new(), + size: 0, + received_at: None, + message_id: None, + in_reply_to: None, + references: None, + from: None, + to: None, + cc: None, + bcc: None, + reply_to: None, + subject: None, + sent_at: None, + preview: None, + has_attachment: false, + text_body: None, + html_body: None, + attachments: None, + body_values: None, + }; + email.from = Some(from.iter().map(|e| addr(e)).collect()); + email.to = Some(to.iter().map(|e| addr(e)).collect()); + email.cc = Some(cc.iter().map(|e| addr(e)).collect()); + email + } + + fn emails(addrs: &[EmailAddress]) -> Vec { + addrs.iter().map(|a| a.email.clone()).collect() + } + + #[test] + fn test_expand_reply_plain_does_not_expand() { + let original = reply_fixture( + vec!["sender@x"], + vec!["recip1@x", "recip2@x"], + vec!["cc1@x"], + ); + let (to, cc) = + expand_reply_recipients(&original, false, Some("me@x"), vec![addr("user@x")]); + assert_eq!(emails(&to), vec!["sender@x"]); + assert_eq!(emails(&cc), vec!["user@x"]); + } + + #[test] + fn test_expand_reply_all_adds_original_recipients() { + let original = reply_fixture( + vec!["sender@x"], + vec!["recip1@x", "recip2@x"], + vec!["cc1@x"], + ); + let (to, cc) = expand_reply_recipients(&original, true, Some("me@x"), vec![]); + assert_eq!(emails(&to), vec!["sender@x", "recip1@x", "recip2@x"]); + assert_eq!(emails(&cc), vec!["cc1@x"]); + } + + #[test] + fn test_expand_reply_all_filters_me_from_to() { + let original = reply_fixture( + vec!["sender@x"], + vec!["recip1@x", "me@x", "recip2@x"], + vec![], + ); + let (to, _) = expand_reply_recipients(&original, true, Some("me@x"), vec![]); + assert_eq!(emails(&to), vec!["sender@x", "recip1@x", "recip2@x"]); + } + + #[test] + fn test_expand_reply_all_filters_me_from_cc() { + let original = reply_fixture(vec!["sender@x"], vec![], vec!["cc1@x", "me@x", "cc2@x"]); + let (_, cc) = expand_reply_recipients(&original, true, Some("me@x"), vec![]); + assert_eq!(emails(&cc), vec!["cc1@x", "cc2@x"]); + } + + #[test] + fn test_expand_reply_all_case_insensitive_me() { + let original = reply_fixture(vec!["sender@x"], vec!["ME@X"], vec!["me@X"]); + let (to, cc) = expand_reply_recipients(&original, true, Some("me@x"), vec![]); + assert_eq!(emails(&to), vec!["sender@x"]); + assert_eq!(emails(&cc), Vec::::new()); + } + + #[test] + fn test_expand_reply_dedupes_overlapping_user_cc_and_reply_all_to() { + // The exact duplicate-send scenario from the bug report: user notices + // preview is missing recipients, adds them as cc to "fix" the preview; + // send path expands reply-all into To AND those addresses appear in CC. + let original = reply_fixture( + vec!["paul@x"], + vec!["sher@x", "dylan@x", "anne@x", "leon@x"], + vec![], + ); + let user_cc = vec![addr("sher@x"), addr("anne@x"), addr("leon@x")]; + let (to, cc) = expand_reply_recipients(&original, true, Some("dylan@x"), user_cc); + // Dylan filtered out; rest in To. + assert_eq!(emails(&to), vec!["paul@x", "sher@x", "anne@x", "leon@x"]); + // Nothing in CC — all user-supplied addresses were already in To. + assert_eq!(emails(&cc), Vec::::new()); + } + + #[test] + fn test_expand_reply_dedupes_duplicates_in_original() { + // Unusual but possible: original.from address also appears in + // original.to (e.g. sender CC'd themselves). + let original = reply_fixture(vec!["x@x"], vec!["x@x", "y@x"], vec![]); + let (to, _) = expand_reply_recipients(&original, true, None, vec![]); + assert_eq!(emails(&to), vec!["x@x", "y@x"]); + } + + #[test] + fn test_expand_reply_without_my_email_still_dedupes() { + // Preview path when identity resolution fails: no "me" filter, but + // dedup should still run. + let original = reply_fixture(vec!["sender@x"], vec!["a@x", "a@x"], vec![]); + let (to, _) = expand_reply_recipients(&original, true, None, vec![]); + assert_eq!(emails(&to), vec!["sender@x", "a@x"]); + } + + #[test] + fn test_expand_reply_preserves_to_order() { + let original = reply_fixture( + vec!["first@x"], + vec!["second@x", "third@x"], + vec!["fourth@x", "fifth@x"], + ); + let (to, cc) = expand_reply_recipients(&original, true, None, vec![]); + assert_eq!(emails(&to), vec!["first@x", "second@x", "third@x"]); + assert_eq!(emails(&cc), vec!["fourth@x", "fifth@x"]); + } + #[test] fn test_pick_identity_none_returns_first() { let identities = vec![ diff --git a/src/mcp/graphql/mutation.rs b/src/mcp/graphql/mutation.rs index 90126c7..a5e45d0 100644 --- a/src/mcp/graphql/mutation.rs +++ b/src/mcp/graphql/mutation.rs @@ -127,7 +127,7 @@ impl MutationRoot { let original = client.get_email(&email_id).await?; let reply_all = all.unwrap_or(false); - let cc_addrs = cc.as_deref().map(parse_addresses).unwrap_or_default(); + let extra_cc = cc.as_deref().map(parse_addresses).unwrap_or_default(); let bcc_addrs = bcc.as_deref().map(parse_addresses).unwrap_or_default(); let subject = if original @@ -140,7 +140,17 @@ impl MutationRoot { format!("Re: {}", original.subject.as_deref().unwrap_or("")) }; - let to_addrs: Vec = original.from.clone().unwrap_or_default(); + // Compute the final recipient lists once, up front. Both PREVIEW + // (for display) and CONFIRM/DRAFT (for the actual send) use these + // exact values — preview and send can't diverge because they share + // the same variables, not the same code path. + let my_email = client.resolve_my_email(from.as_deref()).await; + let (to_addrs, cc_addrs) = crate::jmap::expand_reply_recipients( + &original, + reply_all, + my_email.as_deref(), + extra_cc, + ); if matches!(action, SendAction::Preview) { let nonce = super::types::issue_nonce(nonce_store, ¶ms).await; @@ -196,7 +206,7 @@ impl MutationRoot { .reply_email( &original, &body, - reply_all, + to_addrs, crate::jmap::ComposeParams { cc: cc_addrs, bcc: bcc_addrs,