From 3102bf71a7bf2a9e2e9d52bd6007b192fb6575c5 Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 22 Sep 2026 04:44:47 -0700 Subject: [PATCH 01/25] updated to have newer files --- src/mcp/args.rs | 8 +- src/mcp/file_access.rs | 272 ++++++++++++++++++++++++++++++++++++----- src/mcp/mod.rs | 28 ++++- 3 files changed, 275 insertions(+), 33 deletions(-) diff --git a/src/mcp/args.rs b/src/mcp/args.rs index 0d62742..cc21aae 100644 --- a/src/mcp/args.rs +++ b/src/mcp/args.rs @@ -820,7 +820,7 @@ pub(super) struct DownloadAttachmentsArgs { )] pub(super) expected_uid_validity: u32, #[schemars( - description = "Where to write, resolved against the active session workspace (standalone server: AGENTMAIL_FILE_ROOT). OMIT IT to write to the workspace root — that is the default and is normally what you want. An absolute path is accepted only if it already lies inside the workspace; do not invent one." + description = "Where to write, resolved against the active session workspace (standalone server: AGENTMAIL_FILE_ROOT). OMIT IT to write to the workspace root — that is the default and is normally what you want. An absolute path is accepted only if it already lies inside the workspace or one of the Project's other bound folders; do not invent one." )] pub(super) output_dir: Option, } @@ -845,7 +845,7 @@ pub(super) struct DownloadMessageSourceArgs { )] pub(super) expected_uid_validity: u32, #[schemars( - description = "Where to write, resolved against the active session workspace (standalone server: AGENTMAIL_FILE_ROOT). OMIT IT to write to the workspace root — that is the default and is normally what you want. An absolute path is accepted only if it already lies inside the workspace; do not invent one." + description = "Where to write, resolved against the active session workspace (standalone server: AGENTMAIL_FILE_ROOT). OMIT IT to write to the workspace root — that is the default and is normally what you want. An absolute path is accepted only if it already lies inside the workspace or one of the Project's other bound folders; do not invent one." )] pub(super) output_dir: Option, #[schemars( @@ -880,7 +880,7 @@ pub(super) struct DownloadThreadArgs { )] pub(super) expected_uid_validity: u32, #[schemars( - description = "Where to write, resolved against the active session workspace (standalone server: AGENTMAIL_FILE_ROOT). OMIT IT to write to the workspace root — that is the default and is normally what you want. An absolute path is accepted only if it already lies inside the workspace; do not invent one." + description = "Where to write, resolved against the active session workspace (standalone server: AGENTMAIL_FILE_ROOT). OMIT IT to write to the workspace root — that is the default and is normally what you want. An absolute path is accepted only if it already lies inside the workspace or one of the Project's other bound folders; do not invent one." )] pub(super) output_dir: Option, #[schemars( @@ -926,7 +926,7 @@ pub(super) struct ExportThreadRecordArgs { )] pub(super) purpose: String, #[schemars( - description = "Where to write, resolved against the active session workspace. OMIT IT to write to the workspace root — that is the default and is normally what you want. An absolute path is accepted only if it already lies inside the workspace; do not invent one." + description = "Where to write, resolved against the active session workspace. OMIT IT to write to the workspace root — that is the default and is normally what you want. An absolute path is accepted only if it already lies inside the workspace or one of the Project's other bound folders; do not invent one." )] pub(super) output_dir: Option, #[schemars( diff --git a/src/mcp/file_access.rs b/src/mcp/file_access.rs index e222c27..fc86df0 100644 --- a/src/mcp/file_access.rs +++ b/src/mcp/file_access.rs @@ -13,51 +13,125 @@ //! Standalone mode uses `AGENTMAIL_FILE_ROOT` (or `~/.agentmail/files`). The //! embedded server instead receives the active session workspace in trusted //! request metadata and builds a fresh policy for that request. +//! +//! # More than one root +//! +//! A session's workspace is its project folder, but a Project may bind +//! ADDITIONAL directories — the same list the app announces to an ACP agent as +//! `additionalDirectories` and grants to its sandbox. Those are consented +//! workspace, so a save into one has to succeed: refusing it told the user +//! their own bound folder was "outside the allowed workspace root", which is +//! both wrong and unactionable (2026-09-22). +//! +//! So the policy holds a LIST. The first entry is the PRIMARY root and is +//! privileged in exactly two ways — it is what a relative path joins onto, and +//! it is where an omitted `outputDir` writes — so default behaviour is +//! unchanged and the model still cannot be steered into an additional root by +//! accident. Containment, by contrast, accepts any root in the list: an +//! ABSOLUTE path is allowed if it lies under any one of them. +//! +//! The confinement itself is unweakened. Every root is canonicalized before +//! comparison, `..` is refused lexically, and a symlink out of one root does +//! not land inside another unless the user bound that other folder too — in +//! which case it was already reachable directly. use std::path::{Component, Path, PathBuf}; -/// Confines tool-supplied paths to a sandbox root. See the module docs. +/// Confines tool-supplied paths to the session's granted roots. See the module +/// docs — in particular, why the first root is not just one of the set. #[derive(Debug, Clone)] pub(crate) struct FileAccessPolicy { - /// The sandbox root as configured (resolved to an absolute-ish path but not - /// necessarily canonical or existing yet — [`Self::ensure_root`] does that). - root: PathBuf, + /// The sandbox roots as configured (absolute-ish but not necessarily + /// canonical or existing yet — [`Self::ensure_root`] / [`Self::roots`] do + /// that). NEVER empty: every constructor supplies at least one. + /// + /// `roots[0]` is the primary: relative joins and the default output + /// directory. The rest widen containment only. + roots: Vec, } impl FileAccessPolicy { /// Resolve the sandbox root from the environment, falling back to a /// per-user directory. Never fails: a missing home directory degrades to a /// relative `.agentmail/files` (still a confinement, just under the cwd). + /// + /// Standalone mode has exactly one root — there is no Project to bind + /// additional directories to. pub(crate) fn from_env() -> Self { let root = std::env::var_os("AGENTMAIL_FILE_ROOT") .map(PathBuf::from) .filter(|p| !p.as_os_str().is_empty()) .or_else(|| dirs::home_dir().map(|h| h.join(".agentmail").join("files"))) .unwrap_or_else(|| PathBuf::from(".agentmail").join("files")); - Self { root } + Self { roots: vec![root] } } - /// Build a policy with an explicit root (tests / embedders). - pub(crate) fn with_root(root: impl Into) -> Self { - Self { root: root.into() } + /// Build a policy with a primary root plus additional granted directories. + /// + /// `additional` is filtered to absolute paths: a relative entry would join + /// onto the process cwd, which is not a place anyone consented to. It is + /// also deduplicated against the primary so a Project that binds its own + /// folder does not produce two copies. + pub(crate) fn with_roots( + root: impl Into, + additional: impl IntoIterator, + ) -> Self { + let primary = root.into(); + let mut roots = vec![primary.clone()]; + for extra in additional { + if extra.is_absolute() && extra != primary && !roots.contains(&extra) { + roots.push(extra); + } + } + Self { roots } } - /// Create the root if needed and return its canonical form. + /// The PRIMARY root: created if needed, returned canonical. + /// + /// Only the primary is created on demand. An additional root the user + /// bound and then deleted should fail as absent rather than be silently + /// recreated by an email tool. fn ensure_root(&self) -> Result { - std::fs::create_dir_all(&self.root).map_err(|e| { + let primary = &self.roots[0]; + std::fs::create_dir_all(primary).map_err(|e| { format!( "cannot create the file sandbox root {}: {e}", - self.root.display() + primary.display() ) })?; - self.root.canonicalize().map_err(|e| { + primary.canonicalize().map_err(|e| { format!( "cannot resolve the file sandbox root {}: {e}", - self.root.display() + primary.display() ) }) } + /// Every root in canonical form, primary first. + /// + /// An additional root that cannot be canonicalized (moved, unmounted, + /// never existed) is DROPPED rather than erroring: it is one of several + /// grants, and a stale one must not take down access to the others. The + /// primary is created by [`Self::ensure_root`], so it is always present. + fn roots(&self) -> Result, String> { + let mut out = vec![self.ensure_root()?]; + for extra in &self.roots[1..] { + // Canonicalizing can collapse two configured roots onto one (a + // symlinked bind), so dedupe AFTER resolving rather than before. + if let Ok(canonical) = extra.canonicalize() + && !out.contains(&canonical) + { + out.push(canonical); + } + } + Ok(out) + } + + /// Whether `candidate` (already canonical) lies inside any granted root. + fn contained(roots: &[PathBuf], candidate: &Path) -> bool { + roots.iter().any(|root| candidate.starts_with(root)) + } + /// Resolve a requested path against the root: absolute paths are taken as /// given (still subject to the containment check), relative paths join the /// root. Rejects any `..` component up front — a cheap lexical guard before @@ -78,24 +152,38 @@ impl FileAccessPolicy { }) } + /// Names EVERY granted root, not just the primary. + /// + /// With one root the old message told you where you were allowed to write. + /// With several it would have named one of them and left the model + /// guessing whether the others existed — and the model's next move after a + /// refusal is to invent a path. fn escape_error(&self, requested: &str) -> String { - format!( - "path '{requested}' is outside the allowed workspace root ({})", - self.root.display() - ) + let roots = self + .roots + .iter() + .map(|r| r.display().to_string()) + .collect::>() + .join(", "); + if self.roots.len() == 1 { + format!("path '{requested}' is outside the allowed workspace root ({roots})") + } else { + format!("path '{requested}' is outside the allowed workspace roots ({roots})") + } } /// Confine a file to READ: it must resolve to an existing file within the /// root. Returns the canonical path safe to open. pub(crate) fn confine_read(&self, requested: &str) -> Result { - let root = self.ensure_root()?; - let candidate = Self::resolve(&root, requested)?; + let roots = self.roots()?; + // Relative paths join the PRIMARY root — see the module docs. + let candidate = Self::resolve(&roots[0], requested)?; // canonicalize requires existence, which also resolves symlinks — a // symlink inside the root pointing out lands outside and is rejected. let canonical = candidate .canonicalize() .map_err(|e| format!("cannot access '{requested}': {e}"))?; - if !canonical.starts_with(&root) { + if !Self::contained(&roots, &canonical) { return Err(self.escape_error(requested)); } if !canonical.is_file() { @@ -109,10 +197,12 @@ impl FileAccessPolicy { /// is canonicalized and checked before creation so a symlinked ancestor /// cannot redirect the write outside the root. pub(crate) fn confine_dir(&self, requested: Option<&str>) -> Result { - let root = self.ensure_root()?; + let roots = self.roots()?; + // An omitted `outputDir` writes to the PRIMARY root, never to an + // additional one — the default must stay where the user expects it. let target = match requested.map(str::trim).filter(|s| !s.is_empty()) { - None => root.clone(), - Some(p) => Self::resolve(&root, p)?, + None => roots[0].clone(), + Some(p) => Self::resolve(&roots[0], p)?, }; // Check the nearest existing ancestor's canonical location first. let mut ancestor = target.as_path(); @@ -122,13 +212,13 @@ impl FileAccessPolicy { } match ancestor.parent() { Some(parent) => ancestor = parent, - None => break root.clone(), + None => break roots[0].clone(), } }; let canonical_ancestor = existing .canonicalize() .map_err(|e| format!("cannot resolve '{}': {e}", existing.display()))?; - if !canonical_ancestor.starts_with(&root) { + if !Self::contained(&roots, &canonical_ancestor) { return Err(self.escape_error(requested.unwrap_or_default())); } std::fs::create_dir_all(&target) @@ -136,7 +226,7 @@ impl FileAccessPolicy { let canonical = target .canonicalize() .map_err(|e| format!("cannot resolve '{}': {e}", target.display()))?; - if !canonical.starts_with(&root) { + if !Self::contained(&roots, &canonical) { return Err(self.escape_error(requested.unwrap_or_default())); } Ok(canonical) @@ -157,7 +247,7 @@ mod tests { #[test] fn read_allows_files_inside_and_rejects_escapes() { let root = temp_root("read"); - let policy = FileAccessPolicy::with_root(&root); + let policy = FileAccessPolicy::with_roots(&root, []); // A file inside the root, addressed relatively, is allowed. std::fs::write(root.join("ok.txt"), b"hi").unwrap(); @@ -197,7 +287,7 @@ mod tests { #[test] fn dir_defaults_to_root_and_confines_writes() { let root = temp_root("dir"); - let policy = FileAccessPolicy::with_root(&root); + let policy = FileAccessPolicy::with_roots(&root, []); // No dir → the root itself. let d = policy.confine_dir(None).expect("default dir is the root"); @@ -220,4 +310,130 @@ mod tests { .expect_err(".. must be rejected"); assert!(err.contains(".."), "{err}"); } + + /// THE BUG THIS EXISTS FOR. A Project's additional bound directory is + /// writable, and a directory that was never bound still is not. + /// + /// AgentMail confines to the session's granted roots and only ever knew + /// about the primary one, so saving an attachment into a folder the user + /// had deliberately bound to the Project came back as "outside the allowed + /// workspace root" — the app refusing a grant it had already made + /// (2026-09-22). + #[test] + fn an_additional_root_is_writable_and_an_unbound_directory_is_not() { + let primary = temp_root("multi-primary"); + let bound = temp_root("multi-bound"); + let unbound = temp_root("multi-unbound"); + let policy = FileAccessPolicy::with_roots(&primary, [bound.clone()]); + + // The bound folder accepts a write, addressed absolutely — which is + // the only way to name it, since relative paths join the primary. + let d = policy + .confine_dir(Some(bound.to_str().unwrap())) + .expect("a bound additional root must be writable"); + assert_eq!(d, bound); + + // And a subdirectory of it, created on demand. + let sub = policy + .confine_dir(Some(bound.join("evidence").to_str().unwrap())) + .expect("a subdir of a bound root must be writable"); + assert!(sub.starts_with(&bound) && sub.is_dir()); + + // Reads work the same way. + std::fs::write(bound.join("msg.eml"), b"hi").unwrap(); + let read = policy + .confine_read(bound.join("msg.eml").to_str().unwrap()) + .expect("a file in a bound root must be readable"); + assert!(read.starts_with(&bound)); + + // A folder nobody bound is still refused — widening the grant must not + // become "any absolute path". + let err = policy + .confine_dir(Some(unbound.to_str().unwrap())) + .expect_err("an unbound directory must stay refused"); + assert!(err.contains("outside the allowed workspace roots"), "{err}"); + // The message names every root, so the model is not left guessing. + assert!(err.contains(primary.to_str().unwrap()), "{err}"); + assert!(err.contains(bound.to_str().unwrap()), "{err}"); + } + + /// The primary root keeps its two privileges: relative paths join it, and + /// an omitted `outputDir` lands in it. + /// + /// Without this an additional root could quietly become the default write + /// target — a save the user expected in their project appearing in some + /// other bound folder, which is worse than a refusal because nothing says + /// so. + #[test] + fn the_primary_root_still_owns_relative_paths_and_the_default() { + let primary = temp_root("multi-primary-wins"); + let bound = temp_root("multi-bound-loses"); + let policy = FileAccessPolicy::with_roots(&primary, [bound.clone()]); + + assert_eq!( + policy.confine_dir(None).expect("default dir"), + primary, + "an omitted outputDir writes to the PRIMARY root" + ); + let rel = policy.confine_dir(Some("downloads")).expect("relative dir"); + assert!( + rel.starts_with(&primary) && !rel.starts_with(&bound), + "a relative path joins the PRIMARY root, got {}", + rel.display() + ); + } + + /// A stale additional root is dropped, not fatal. + /// + /// Bindings outlive the folders they name — a bound directory gets moved + /// or unmounted. That must cost access to THAT root only; taking the whole + /// session's file access down with it would turn a missing folder into + /// "AgentMail cannot save anything". + #[test] + fn a_missing_additional_root_does_not_break_the_others() { + let primary = temp_root("multi-stale-primary"); + let bound = temp_root("multi-stale-bound"); + let gone = std::env::temp_dir().join("agentmail-sandbox-multi-stale-gone"); + let _ = std::fs::remove_dir_all(&gone); + + let policy = FileAccessPolicy::with_roots(&primary, [gone.clone(), bound.clone()]); + + assert_eq!( + policy.confine_dir(None).expect("primary still works"), + primary + ); + assert_eq!( + policy + .confine_dir(Some(bound.to_str().unwrap())) + .expect("a live additional root still works"), + bound + ); + // The absent one is simply not a root. + assert!(policy.confine_dir(Some(gone.to_str().unwrap())).is_err()); + } + + /// Relative and duplicate entries never become roots. + /// + /// A relative entry would join the process cwd — a place nobody consented + /// to — and a Project that binds its own folder must not produce a + /// duplicate that shows up twice in the refusal message. + #[test] + fn additional_roots_are_filtered_to_absolute_and_deduplicated() { + let primary = temp_root("multi-filter"); + let policy = FileAccessPolicy::with_roots( + &primary, + [ + PathBuf::from("relative/not/allowed"), + primary.clone(), + primary.clone(), + ], + ); + let err = policy + .confine_dir(Some("/definitely/not/bound")) + .unwrap_err(); + assert!( + err.contains("outside the allowed workspace root ("), + "exactly one root survived, so the message is singular: {err}" + ); + } } diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 920d138..6e0a6cd 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -30,7 +30,16 @@ use std::{panic::AssertUnwindSafe, sync::Arc}; const PREWARM_CONCURRENCY: usize = 3; const PREWARM_ACCOUNT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); +/// The session's PRIMARY workspace root, as a JSON string. pub const WORKSPACE_ROOT_META_KEY: &str = "io.agentmuse/workspaceRoot"; +/// The Project's OTHER bound directories, as a JSON array of strings. +/// +/// A separate key rather than widening [`WORKSPACE_ROOT_META_KEY`] to +/// string-or-array: the primary root is load-bearing on its own (relative +/// paths join it, an omitted `outputDir` lands in it), so it keeps a field +/// whose type says there is exactly one. Absent or empty means what it always +/// meant — one root — so an older app, or the standalone server, is unchanged. +pub const ADDITIONAL_ROOTS_META_KEY: &str = "io.agentmuse/additionalWorkspaceRoots"; // --------------------------------------------------------------------------- // Helper functions @@ -301,7 +310,24 @@ impl AgentMailServer { None, )); } - Ok(file_access::FileAccessPolicy::with_root(root)) + // The Project's other bound directories, if the app sent any. Each is + // filtered to an absolute path by `with_roots`; a malformed entry is + // dropped rather than failing the call, because the primary root + // alone is still a working session. + let additional: Vec = meta + .get(ADDITIONAL_ROOTS_META_KEY) + .and_then(serde_json::Value::as_array) + .map(|entries| { + entries + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|p| !p.is_empty()) + .map(std::path::PathBuf::from) + .collect() + }) + .unwrap_or_default(); + Ok(file_access::FileAccessPolicy::with_roots(root, additional)) } /// Combined tool router — referenced by `#[tool_handler]`'s default From 1fbc0190cec38dd6da1c8f1fdb1305e928db092d Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:41:39 -0700 Subject: [PATCH 02/25] imap changes --- CHANGELOG.md | 67 +- Cargo.lock | 451 +++-- Cargo.toml | 17 +- DECISIONS.md | 118 +- DESIGN.md | 97 +- MCP.md | 126 +- README.md | 65 +- .../async-imap-surface-tagged-status.md | 12 +- docs/patches/imap-proto-uidfetch.md | 46 +- examples/probe_capabilities.rs | 1 + examples/probe_header_fields.rs | 1 + examples/probe_uid_window.rs | 1 + examples/probe_uidonly.rs | 6 +- src/config.rs | 202 ++- src/connection.rs | 167 +- src/draft.rs | 436 ++++- src/header_cache.rs | 3 + src/imap_client.rs | 281 +++- src/lib.rs | 755 +++++++-- src/mailbox_catalog.rs | 93 +- src/main.rs | 144 +- src/mcp/args.rs | 27 +- src/mcp/mod.rs | 12 +- src/mcp/prompts.rs | 4 +- src/mcp/resources.rs | 2 +- src/mcp/tasks.rs | 42 + src/mcp/tools_read.rs | 44 +- src/mcp/tools_write.rs | 7 +- src/mcp/wire.rs | 79 + src/parser.rs | 116 +- src/provider.rs | 1 + src/types.rs | 60 +- tests/mcp_integration.rs | 174 +- vendor/imap-proto/.cargo_vcs_info.json | 4 +- vendor/imap-proto/Cargo.lock | 17 +- vendor/imap-proto/Cargo.toml | 12 +- vendor/imap-proto/LICENSE-APACHE | 201 +++ vendor/imap-proto/LICENSE-MIT | 25 + vendor/imap-proto/README.md | 81 +- vendor/imap-proto/examples/parse_response.rs | 2 +- .../src/{parser => }/bodystructure.rs | 8 +- vendor/imap-proto/src/builders/command.rs | 35 +- vendor/imap-proto/src/{parser => }/core.rs | 120 +- vendor/imap-proto/src/{parser => }/gmail.rs | 27 +- vendor/imap-proto/src/lib.rs | 122 +- vendor/imap-proto/src/parser/mod.rs | 30 - .../src/parser/rfc3501/body_structure.rs | 535 ------ vendor/imap-proto/src/parser/rfc3501/mod.rs | 853 ---------- vendor/imap-proto/src/parser/rfc4314.rs | 119 -- vendor/imap-proto/src/{parser => }/rfc2087.rs | 151 +- vendor/imap-proto/src/{parser => }/rfc2971.rs | 49 +- .../src/{parser => }/rfc3501/body.rs | 73 +- .../imap-proto/src/rfc3501/body_structure.rs | 847 ++++++++++ vendor/imap-proto/src/rfc3501/mod.rs | 1461 +++++++++++++++++ .../src/{types/acls.rs => rfc4314.rs} | 114 +- vendor/imap-proto/src/{parser => }/rfc4315.rs | 59 +- vendor/imap-proto/src/{parser => }/rfc4551.rs | 14 +- vendor/imap-proto/src/{parser => }/rfc5161.rs | 24 +- vendor/imap-proto/src/{parser => }/rfc5256.rs | 11 +- vendor/imap-proto/src/{parser => }/rfc5464.rs | 83 +- vendor/imap-proto/src/{parser => }/rfc7162.rs | 16 +- vendor/imap-proto/src/{parser => }/tests.rs | 435 +++-- vendor/imap-proto/src/types.rs | 978 ----------- 63 files changed, 6599 insertions(+), 3564 deletions(-) create mode 100644 vendor/imap-proto/LICENSE-APACHE create mode 100644 vendor/imap-proto/LICENSE-MIT rename vendor/imap-proto/src/{parser => }/bodystructure.rs (92%) rename vendor/imap-proto/src/{parser => }/core.rs (69%) rename vendor/imap-proto/src/{parser => }/gmail.rs (81%) delete mode 100644 vendor/imap-proto/src/parser/mod.rs delete mode 100644 vendor/imap-proto/src/parser/rfc3501/body_structure.rs delete mode 100644 vendor/imap-proto/src/parser/rfc3501/mod.rs delete mode 100644 vendor/imap-proto/src/parser/rfc4314.rs rename vendor/imap-proto/src/{parser => }/rfc2087.rs (66%) rename vendor/imap-proto/src/{parser => }/rfc2971.rs (85%) rename vendor/imap-proto/src/{parser => }/rfc3501/body.rs (50%) create mode 100644 vendor/imap-proto/src/rfc3501/body_structure.rs create mode 100644 vendor/imap-proto/src/rfc3501/mod.rs rename vendor/imap-proto/src/{types/acls.rs => rfc4314.rs} (63%) rename vendor/imap-proto/src/{parser => }/rfc4315.rs (68%) rename vendor/imap-proto/src/{parser => }/rfc4551.rs (72%) rename vendor/imap-proto/src/{parser => }/rfc5161.rs (57%) rename vendor/imap-proto/src/{parser => }/rfc5256.rs (85%) rename vendor/imap-proto/src/{parser => }/rfc5464.rs (84%) rename vendor/imap-proto/src/{parser => }/rfc7162.rs (74%) rename vendor/imap-proto/src/{parser => }/tests.rs (64%) delete mode 100644 vendor/imap-proto/src/types.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c09669..23a5ee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ --- created: 2026-05-29T19:20 -updated: 2026-08-04T00:00 +updated: 2026-10-06T00:00 --- # Changelog @@ -13,12 +13,77 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Sender identity** — `display_name` in an account's config makes drafts + `From: Mark Blake `. `create_draft` and `update_draft` take an + optional `from` (`Name
` or an address) that must be one of the + account's identities — the primary address, then its aliases — and refuse any + other address with the list of usable ones. Both results report the `from` + actually written. The CLI gains `create-draft --from`, and `configure` asks + for "Your name (shown on drafts)". `AccountConfig::identities()`, + `with_display_name` and `validate_display_name` are public. +- **`list_identities` tool** (36 tools) — an account's configured identities + plus the From addresses among its newest Sent messages (default 200, max + 1000; `EXAMINE` + `BODY.PEEK`), each with a count, the display names used and + when it was last used. Evidence only: a discovered address is not sendable + until it is added as an alias. `list_accounts` now carries each account's + `displayName` and `addresses`. - **Evidence-grade RFC822 archive tools** — `download_message_source` writes one exact `BODY.PEEK[]` result directly to a create-new private file, returning its SHA-256, parsed message metadata, and a contemporaneous DNS-backed local DKIM result. `download_thread` applies the same contract to a caller-selected set of up to 100 UIDs and creates a JSON manifest. +### Changed + +- **Replies are From the address the original reached** — the original's own + `From` for a follow-up to this account's mail, else the first identity in its + `To`, then `Cc`. `update_draft` keeps the draft's current `From` (name + included) when it is one of the account's identities instead of overwriting + it with the primary address. +- **Replies go to every Reply-To address.** `MessageInfo.reply_to` is now + `Vec` (JSON `replyTo` is an array), where only the first address was + kept before. +- **Breaking library API (0.7.0)** — `create_draft_with_headers`, + `create_reply_draft` and `update_draft` take `from: Option<&str>`; + `CreateDraftResponse`/`UpdateDraftResponse` gain `from`; `AccountConfig` + gains `display_name`; `AccountInfo` gains `display_name` and `addresses`. +- **A distinct login is never a sending identity** unless listed in `aliases`; + an alias equal to the login is now kept rather than normalized away. +- **Dependencies** — async-imap 0.11.3 → 0.12.0 (LOGIN no longer fails on a + `NO` for another tag; unicode banners parse) with the vendored imap-proto + rebased onto 0.17.0 (nom 8) and its UIDFETCH patch re-applied; mail-auth + 0.12.1 → 0.13.3; dirs 6 → 7; semver-compatible updates across the lockfile. + rusqlite stays at 0.39 (the embedding app's sqlx-sqlite caps libsqlite3-sys + below 0.38) and rmcp at 2.2.0 (3.x removes the task API; its own decision in + the embedding workspace). + +### Fixed + +- **Dead pooled sessions passed their liveness check.** async-imap's `NOOP` + treats end-of-stream as success, and on macOS a reset connection reads as + end-of-stream, so after sleep or a server `BYE` the pool handed out dead + sessions and drafts reported a false "APPEND outcome is ambiguous". `NOOP` now + passes only on its own tagged `OK`. +- **Pool and catalog timers stopped while the Mac slept.** Idle eviction, the + `[LIMIT]` login cooldown and the mailbox-catalog TTL now run on the wall + clock; a clock set back counts as expired. +- **Replying to a sender with a comma or `@` in their name failed.** Display + names holding RFC 5322 specials are quoted (`"Blake, Mark" `), and an + agent-written `Blake, Mark ` is accepted as one mailbox — while + `Bob , Alice ` is still refused rather than losing Bob. +- **A draft for an account with no email address** failed as "Invalid email + address 'johnappleseed'"; it now says to set `email`. +- **`download_attachments` returned the bare filename as `path`**; it is now the + absolute, canonical path, as `download_message_source` returns. +- **An expired task's status watcher waited forever** — the TTL prune aborted + the worker without publishing a result. Expiry now publishes a cancelled one. +- **`configure` wrote Rust escapes into TOML** (`\u{301}` for a decomposed + accent), producing a config file that failed to parse; every string is now + written by the TOML serializer. +- **README claimed `~/.config/agentmail/config.toml` works on macOS**; only + `~/Library/Application Support/agentmail/config.toml` (or + `AGENTMAIL_CONFIG`) is read. + ### Security - **Archive write confinement** — message source and manifest filenames must be diff --git a/Cargo.lock b/Cargo.lock index e950cd6..065f359 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,7 +32,7 @@ dependencies = [ [[package]] name = "agentmail" -version = "0.6.0" +version = "0.7.0" dependencies = [ "apple-native-keyring-store", "async-imap", @@ -56,7 +56,7 @@ dependencies = [ "proptest", "psl", "pulldown-cmark", - "rand 0.10.2", + "rand 0.10.3", "reqwest", "rmcp", "rusqlite", @@ -64,7 +64,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-native-tls", "tokio-util", @@ -229,9 +229,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.44" +version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "515a1f282e33d55983c499d7e9e87082e81cbc32974825bf9032f928392d5844" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" dependencies = [ "compression-codecs", "compression-core", @@ -241,19 +241,19 @@ dependencies = [ [[package]] name = "async-imap" -version = "0.11.3" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a6728e0f7931b36d725ac234fcb02539e9f7888dbeaaa8a18d9ea5792181570" +checksum = "f97b87216c9f0ccc63c516263169085fa34bba25633f6d96c6a9b9ca1fd5f70a" dependencies = [ "async-channel 2.5.0", "async-compression", - "base64 0.22.1", + "base64 0.23.1", "bytes", "chrono", - "futures", + "futures-util", "imap-proto", "log", - "nom 7.1.3", + "nom", "pin-project", "pin-utils", "self_cell", @@ -270,7 +270,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -349,9 +349,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -447,9 +447,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.4" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" dependencies = [ "find-msvc-tools", "jobserver", @@ -459,9 +459,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -527,9 +527,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -537,9 +537,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -549,21 +549,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.4" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "cmake" @@ -592,9 +592,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.39" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fe67f2944eef52fc7b106b8c9450d243a88701a0c065f7f57235e76abaed7df" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" dependencies = [ "compression-core", "flate2", @@ -647,6 +647,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpubits" version = "0.1.1" @@ -673,9 +679,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.1" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] @@ -688,18 +694,18 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam-channel" -version = "0.5.16" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -707,18 +713,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.20" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crypto-common" @@ -892,9 +898,9 @@ dependencies = [ [[package]] name = "dirs" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" dependencies = [ "dirs-sys", ] @@ -919,7 +925,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -960,9 +966,9 @@ dependencies = [ [[package]] name = "either" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "email-encoding" @@ -982,11 +988,16 @@ checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -1065,9 +1076,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fast_html2md" -version = "0.0.62" +version = "0.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88587bc3328375d9866f0f71ffcfe5768fe0df79dde79c79216239711868f77f" +checksum = "22fb851eabfefa3a87a42cc06888796411386736e3c888973497be2f95c4af6a" dependencies = [ "auto_encoder", "futures-util", @@ -1086,9 +1097,9 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "flate2" @@ -1199,7 +1210,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -1298,9 +1309,9 @@ checksum = "c3531d702d6c1a3ba92a5fb55a404c7b8c476c8e7ca249951077afcbe4bc807f" [[package]] name = "h2" -version = "0.4.19" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" dependencies = [ "atomic-waker", "bytes", @@ -1364,9 +1375,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hickory-net" -version = "0.26.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084e7bd6a377435d568f652153e571b50970d7ccc1d1eeec0519f834632287e1" +checksum = "c480823ed7c2c5d0f09c41020cb6b7c28029ce60ec42dc942158dcf22f8e0a4d" dependencies = [ "async-trait", "bitflags", @@ -1382,12 +1393,12 @@ dependencies = [ "jni", "lru-cache", "parking_lot", - "rand 0.10.2", + "rand 0.10.3", "ring", "rustls", "rustls-pki-types", "rustls-platform-verifier", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tinyvec", "tokio", @@ -1398,9 +1409,9 @@ dependencies = [ [[package]] name = "hickory-proto" -version = "0.26.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e2da0694c15b44c6f68a6b05e0233617008c54080e31d6eb848d858a9c5b38d" +checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d" dependencies = [ "bitflags", "data-encoding", @@ -1409,10 +1420,10 @@ dependencies = [ "jni", "once_cell", "prefix-trie", - "rand 0.10.2", + "rand 0.10.3", "ring", "rustls-pki-types", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tinyvec", "tracing", @@ -1421,9 +1432,9 @@ dependencies = [ [[package]] name = "hickory-resolver" -version = "0.26.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4f9f4603319422d482738f3f6fe5aac03157fdbfed1cd85a3ff45adb09072f" +checksum = "3f3da5255c95d5a716857d54b5b8f4e8d67c3484d3beaaaae2ce25063b3ba981" dependencies = [ "cfg-if", "futures-util", @@ -1436,12 +1447,12 @@ dependencies = [ "ndk-context", "once_cell", "parking_lot", - "rand 0.10.2", + "rand 0.10.3", "resolv-conf", "rustls", "smallvec", "system-configuration", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-rustls", "tracing", @@ -1512,18 +1523,18 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", @@ -1542,9 +1553,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -1557,16 +1568,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -1716,16 +1728,16 @@ dependencies = [ [[package]] name = "imap-proto" -version = "0.16.7" +version = "0.17.0" dependencies = [ - "nom 7.1.3", + "nom", ] [[package]] name = "indexmap" -version = "2.14.1" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -1766,9 +1778,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.1" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" dependencies = [ "serde", ] @@ -1797,7 +1809,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.20", + "thiserror 2.0.21", "walkdir", "windows-link", ] @@ -1846,9 +1858,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -1866,9 +1878,9 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "lettre" @@ -1882,15 +1894,15 @@ dependencies = [ "httpdate", "idna", "mime", - "nom 8.0.0", + "nom", "quoted_printable", ] [[package]] name = "libc" -version = "0.2.189" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "libdbus-sys" @@ -1903,9 +1915,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.23" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "libc", ] @@ -1970,7 +1982,7 @@ dependencies = [ "mime", "precomputed-hash", "selectors", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -1990,12 +2002,12 @@ dependencies = [ "itoa", "log", "md-5", - "nom 8.0.0", - "rand 0.10.2", + "nom", + "rand 0.10.3", "rangemap", "sha2 0.11.0", "stringprep", - "thiserror 2.0.20", + "thiserror 2.0.21", "weezl", ] @@ -2010,15 +2022,15 @@ dependencies = [ [[package]] name = "lru-slab" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "mail-auth" -version = "0.12.1" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faa246141b9a9194be268605d9db7228784a0eb347d155eeaa223181037693d0" +checksum = "8505122ba86e1f4adeb664196c1e787c3f29bb6e7c128e4a366d47d209911440" dependencies = [ "flate2", "getrandom 0.2.17", @@ -2028,6 +2040,7 @@ dependencies = [ "idna", "mail-builder", "mail-parser", + "memchr", "ring", "rustls-pki-types", "serde", @@ -2037,21 +2050,22 @@ dependencies = [ [[package]] name = "mail-builder" -version = "0.4.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900998f307338c4013a28ab14d760b784067324b164448c6d98a89e44810473b" +checksum = "ccbc27cb02a58a311a404769a22f5abf63fe3173b290d80be7c534f9c7023151" dependencies = [ "gethostname", ] [[package]] name = "mail-parser" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b3a9080c1fb8190e232df37a10aa1b3d6b08be084e537069913f025e0ce86c5" +checksum = "4ec00bda90c6e645a54506c630c2820cd6b1890cfd2b0a169b50f74b2b8c7c86" dependencies = [ "encoding_rs", "hashify", + "memchr", ] [[package]] @@ -2085,12 +2099,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.9.1" @@ -2103,9 +2111,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", @@ -2129,6 +2137,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "native-tls" version = "0.2.18" @@ -2158,16 +2172,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nom" version = "8.0.0" @@ -2536,9 +2540,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" [[package]] name = "pkg-config" @@ -2563,9 +2567,9 @@ dependencies = [ [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "ppv-lite86" @@ -2595,9 +2599,9 @@ dependencies = [ [[package]] name = "printpdf" -version = "0.12.7" +version = "0.12.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f4d083138eb053f9af08c37069f506a1afc90667db3475d07bb271887da2f40" +checksum = "95d50369c5e18e18d4716bd855172db3a70f9e82c966687ae051ea58138f285c" dependencies = [ "allsorts-azul", "base64 0.22.1", @@ -2657,9 +2661,9 @@ dependencies = [ [[package]] name = "psl" -version = "2.1.228" +version = "2.1.239" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158b8294dde3909df5a4ec68a35f8c39720151834b1f87050b87b37435150011" +checksum = "5736f71353f867ae262bd123b86ddb161f50a2db0300ab762c8f4a0e659635d3" dependencies = [ "psl-types", ] @@ -2696,9 +2700,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quinn" -version = "0.11.11" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", @@ -2708,7 +2712,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -2716,22 +2720,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.17" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "aws-lc-rs", "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand 0.10.3", "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -2739,9 +2743,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" dependencies = [ "cfg_aliases", "libc", @@ -2799,9 +2803,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -2894,13 +2898,12 @@ dependencies = [ [[package]] name = "redox_users" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ - "getrandom 0.2.17", "libredox", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -2920,7 +2923,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -2954,11 +2957,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "encoding_rs", "futures-core", @@ -3028,7 +3031,7 @@ dependencies = [ "schemars", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-util", "tracing", @@ -3054,7 +3057,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3088,9 +3091,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags", "errno", @@ -3101,9 +3104,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -3139,9 +3142,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", @@ -3160,9 +3163,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier-android" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" @@ -3235,7 +3238,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -3325,7 +3328,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -3336,7 +3339,7 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -3450,9 +3453,9 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "slab" @@ -3462,9 +3465,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.0" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" dependencies = [ "serde", ] @@ -3551,9 +3554,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.4" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -3571,13 +3574,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -3631,11 +3634,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -3651,13 +3654,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -3700,24 +3703,15 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ba2077be5cd93c7408c849e45a4ab261b59f923f4bcdee2a724b8ed5a175a77" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ "bytes", "libc", @@ -3738,7 +3732,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -3753,9 +3747,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -3777,9 +3771,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.5+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "indexmap", "serde_core", @@ -3946,9 +3940,9 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicase" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" [[package]] name = "unicode-bidi" @@ -3970,9 +3964,9 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-joining-type" @@ -4027,9 +4021,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.26.0" +version = "1.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -4075,9 +4069,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -4099,9 +4093,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -4112,19 +4106,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4132,31 +4127,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -4404,30 +4399,30 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "6400fc4bb7426f6faac3fb07566122bb18622e3022bd2c36426a92ed489b1dbe" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "99c27cc7525bad4df59a45e073c45b2c2aa820efb588234cc23a547ec69a34db" dependencies = [ "proc-macro2", "quote", @@ -4445,21 +4440,21 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" dependencies = [ "zeroize_derive", ] @@ -4505,14 +4500,14 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] name = "zlib-rs" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index b25a081..d5c4084 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,15 @@ [workspace] -# Local imap-proto fork adding RFC 9586 UIDFETCH response parsing (the enabler -# for Yahoo/AOL UID Mode / ENABLE UIDONLY, which lifts the visible-window -# limit). Applies to async-imap's transitive imap-proto too. See +# Local imap-proto 0.17 fork adding RFC 9586 UIDFETCH response parsing (the +# enabler for Yahoo/AOL UID Mode / ENABLE UIDONLY, which lifts the +# visible-window limit). Applies to async-imap's transitive imap-proto too. See # docs/patches/imap-proto-uidfetch.md; upstream when possible to drop the fork. [patch.crates-io] imap-proto = { path = "vendor/imap-proto" } [package] name = "agentmail" -version = "0.6.0" +version = "0.7.0" edition = "2024" rust-version = "1.94" description = "IMAP email client, MCP server, and CLI — read, search, delete, unsubscribe, and manage mailboxes" @@ -33,27 +33,30 @@ path = "src/main.rs" # IMAP protocol (tokio runtime). `compress` enables optional RFC 4978 # COMPRESS=DEFLATE, negotiated after login only when the server advertises it. # It materially reduces transfer size for large full-header scans. -async-imap = { version = "0.11", default-features = false, features = [ +async-imap = { version = "0.12", default-features = false, features = [ "runtime-tokio", "compress", ] } base64 = "0.23" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4", features = ["derive"] } -dirs = "6" +dirs = "7" fast_html2md = "0.0" futures = "0.3" hashbrown = { version = "0.17", features = ["rayon", "serde"] } idna = "1" lettre = { version = "0.11", default-features = false, features = ["builder"] } mail-parser = "0.11" -mail-auth = { version = "0.12.1", default-features = false, features = ["ring"] } +mail-auth = { version = "0.13.3", default-features = false, features = ["ring"] } native-tls = "0.2" parking_lot = "0.12" percent-encoding = "2" printpdf = { version = "0.12", default-features = false } psl = "2" reqwest = { version = "0.13", features = ["json"] } +# Pinned to the bridge's rmcp. 3.x removes the task API this server's background +# tools are built on; the move is one decision across the Agent Muse bridge, +# agentmail and eventkit-rs, deferred to its own ADR in the embedding workspace. rmcp = { version = "=2.2.0", features = ["server", "macros", "transport-io"] } # Held at 0.39 (libsqlite3-sys ^0.37) so embedding workspaces that also carry # sqlx-sqlite 0.9 (libsqlite3-sys >=0.30.1,<0.38) can converge on one native diff --git a/DECISIONS.md b/DECISIONS.md index e7a563d..d3cd72f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -4,6 +4,120 @@ Architectural decisions, deferred work, and rationale for future reference. --- +## 0.7.0 — A Draft Is From One Of The Account's Own Identities + +### Decision + +A draft's `From` is RESOLVED, never echoed. `draft::resolve_from` returns one of +`AccountConfig::identities()` — the primary (`email`, or an email-shaped login +when no `email` is set), then the aliases — under the configured +`display_name`, so `display_name = "Mark Blake"` makes drafts +`From: Mark Blake `. `create_draft` and `update_draft` take an +optional `from` (`Name
` or an address), and both results report the +`from` actually written. `list_accounts` lists each account's identities, and +`list_identities` adds what its Sent mail actually used. + +Before this, `From` was always the bare, lowercased `canonical_email()` — or the +LOGIN NAME when there was none, which failed inside lettre as "Invalid email +address 'johnappleseed'". No tool could choose a sender, the config had no +name, and `update_draft` overwrote whatever `From` another client had set. + +### Why `from` is refused outside `identities()` + +An agent can be asked — or prompt-injected — to write a draft "from" anything. +A `From` the account does not own is at best rewritten or rejected by the +provider at send time, and at worst accepted by a permissive server as a spoof +the user never meant. The account's configuration is the only authority on +what the user sends as, so `from` must match it. The refusal lists the +identities and names the fix (add the address as an alias), which turns a dead +end into one settings change. + +A distinct email-shaped login is NOT an identity. A login is a credential, not +an address the user chose to publish (MCP.md has always kept logins out of +`list_accounts`); it stays in `canonical_addresses()`, which only recognizes the +account's own mail. Listing it in `aliases` makes it sendable, so normalization +no longer drops an alias equal to the login. + +### Why a reply uses the identity the original was addressed to + +Mail sent to `sales@` and answered from `mark@` leaks the personal address and +reads as a different correspondent; every mainstream client answers from the +address the message reached. The order is the original's own `From` when it is +ours (a follow-up to our own mail stays on the address it went out from), then +the first identity in its `To`, then its `Cc`. Mail that reached us by Bcc names +no identity, so it falls back to the primary. + +### Why `update_draft` preserves `From` + +`update_draft` replaced `From` with the primary on every edit, so a draft the +user began in Mail.app from an alias silently changed sender when an agent +fixed a typo. Without `from`, the replacement now keeps the current `From` — +name included, the display name filling a blank one — when that address is +one of ours. A foreign `From` is replaced by the default, since keeping it would +let an old draft carry a sender the account no longer owns. + +### Why the Sent scan, not provider APIs + +IMAP has no identity listing. Gmail's `sendAs` settings and Fastmail's JMAP +`Identity` both need OAuth or API tokens, and the app-password accounts Agent +Muse configures hold neither. What an account has actually sent is readable on +every IMAP server: `list_identities` reads the `From` of the newest N Sent +messages with `BODY.PEEK`. That is EVIDENCE, not permission — it never feeds +`from` validation, so a forged or one-off `From` sitting in Sent cannot widen +what a draft may claim. A discovered address must be added as an alias first. + +### Why `from` is in the results, and derived + +A caller cannot predict the reply rule or a preserved sender, so the result says +what was written. It is formatted from the resolved mailbox, never copied from +the request, and with our own quoting rather than lettre's `Mailbox` `Display` +(which fails on a CR or LF in the name): a name with a comma comes back quoted +and parses as one mailbox. + +### Consequence + +`AccountConfig` gains `display_name` (validated: no control characters, at most +256 characters; `validate_display_name` is exported so an embedder applies the +same rule). The draft facade (`create_draft_with_headers`, `create_reply_draft`, +`update_draft`) gains `from`, and `MessageInfo.reply_to` becomes `Vec` +so a reply goes to EVERY Reply-To address — a breaking release, 0.7.0. 35 tools +→ 36. Pinned by `the_default_sender_is_the_display_name_on_the_primary_address`, +`an_explicit_sender_must_be_one_of_the_accounts_identities`, +`a_reply_is_from_the_identity_the_original_was_sent_to`, +`an_updated_draft_keeps_a_sender_of_ours_and_replaces_a_foreign_one`, +`identities_are_the_primary_then_aliases_never_a_distinct_login`, and +`draft_tools_take_a_sender_and_refuse_one_the_account_does_not_own`. + +## 0.7.0 — Liveness Means A Tagged OK, And Pool Clocks Count Sleep + +### Decision + +`imap_client::ping` sends `NOOP` by hand and passes only on its own tagged +`OK`; end-of-stream and an untagged `BYE` are a lost connection. The pool's idle +stamps, the `[LIMIT]` login cooldown and the mailbox-catalog TTL are measured on +the WALL clock (`SystemTime`), and a clock set back past a stamp counts as +expired. + +### Rationale + +async-imap's `noop()` drains with `take_while(filter)`, so a stream that simply +ends is a successful NOOP. On macOS, security-framework maps a reset connection +to a zero-byte read — that same end — so after sleep or a server `BYE` every +pooled session passed its ping, and the next mutation failed on the dead +socket; a draft APPEND then reported an "ambiguous" outcome it did not have. +`async_imaps_own_noop_reports_a_closed_stream_as_alive` pins the upstream +behavior, so the day it changes is visible. + +`Instant` on macOS is `CLOCK_UPTIME_RAW`, which stops while the machine sleeps. +A session idled overnight therefore looked seconds old and was handed out +instead of evicted, a cooldown resumed where it paused, and the catalog served a +layout loaded hours earlier. The server's clocks run through our sleep, so ours +must too. A backwards wall clock cannot say how much time passed; treating it +as expired costs at most one reconnect or one LIST, while trusting it could +stretch a cooldown by however far the clock moved. + +--- + ## 0.5.0 — Two Tool Pairs Become One Tool Each ### `add_flags` + `remove_flags` → `update_flags` @@ -62,7 +176,9 @@ drive permission prompts. 37 tools → 35. The count is asserted in two places (`tool_schemas_are_ref_free`, `tools_list_has_35_annotated_tools`) precisely so -a drift like this cannot land without the docs being updated with it. Pinned by +a drift like this cannot land without the docs being updated with it. (0.7.0 +added `list_identities`: 36, and the second guard is now +`tools_list_has_36_annotated_tools`.) Pinned by `update_flags_exposes_add_remove_and_color_as_one_call`, `create_draft_absorbs_the_reply_form`, and `a_reply_draft_refuses_recipients_it_would_derive`. diff --git a/DESIGN.md b/DESIGN.md index 669628b..528aab8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2,7 +2,7 @@ ## Overview -Agentmail is a cross-platform IMAP email client library with an MCP (Model Context Protocol) server for AI assistant integration. It provides 31 tools and 6 prompts for reading, searching, composing, organizing, archiving, and managing email across multiple accounts. [MCP.md](MCP.md) is the authoritative wire-contract catalog. +Agentmail is a cross-platform IMAP email client library with an MCP (Model Context Protocol) server for AI assistant integration. It provides 36 tools and 6 prompts for reading, searching, composing, organizing, archiving, and managing email across multiple accounts. [MCP.md](MCP.md) is the authoritative wire-contract catalog. MCP protocol: [2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25) (also negotiates 2025-06-18, 2025-03-26, and 2024-11-05) | rmcp 2.2 @@ -20,7 +20,7 @@ graph TB end subgraph "agentmail-mcp (in-process)" - MCP[AgentMailServer
31 tools, 6 prompts, tasks] + MCP[AgentMailServer
36 tools, 6 prompts, tasks] MK[Agentmail Facade] POOL[ConnectionPool
provider-aware cap/account] CRED[Credential Resolver] @@ -54,7 +54,7 @@ graph LR subgraph "Standalone" CLI[agentmail serve] -->|stdio| MCP1[AgentMailServer] MCP1 --> MK1[Agentmail] - MK1 -->|config.toml| FS[~/.config/agentmail/] + MK1 -->|config.toml| FS[platform config dir/agentmail/] end subgraph "In-Process (Agent App)" @@ -68,10 +68,13 @@ graph LR | --------------- | --------------------------------- | ----------------------------------------------- | | Binary | `agentmail serve` | None (library) | | Transport | stdio | DuplexStream | -| Account config | `~/.config/agentmail/config.toml` | Passed at spawn via `serve_on()` | +| Account config | `/agentmail/config.toml`¹ | Passed at spawn via `serve_on()` | | Password source | keyring (agentmail service) | keyring (agent service) | | Entry point | `main.rs` | `agentmail_mcp::serve_on(transport, agentmail)` | +¹ `~/Library/Application Support` on macOS, `$XDG_CONFIG_HOME` (`~/.config`) on +Linux, `%APPDATA%` on Windows; `AGENTMAIL_CONFIG` overrides it everywhere. + ## Crate Structure ``` @@ -134,7 +137,11 @@ sequenceDiagram ``` - Default 1 concurrent IMAP operation for Yahoo/AOL and 3 otherwise; configurable from 1 through 32 -- Sessions validated with NOOP before reuse +- Sessions validated with NOOP before reuse — and only the NOOP's own tagged + `OK` counts; end-of-stream or `BYE` means dead (async-imap's `noop()` reports a + closed stream as success) +- Idle age, the `[LIMIT]` login cooldown and the mailbox-catalog TTL run on the + wall clock: `Instant` stops while a Mac sleeps, the server's clocks do not - Stale sessions dropped, fresh ones created on demand - `PooledSession` auto-releases semaphore permit on drop @@ -160,45 +167,53 @@ timeout/drop, and every `Secret` debug representation is redacted. ## MCP Tools +[MCP.md](MCP.md) is the authoritative catalog; this is the shape. + ### Read Operations (read_only_hint = true) -| Tool | Description | -| --------------------- | ---------------------------------------------------------------- | -| `list_accounts` | List configured IMAP accounts | -| `list_mailboxes` | List mailboxes with counts, attributes (noSelect, noInferiors), and RFC 6154 roles | -| `list_capabilities` | Query IMAP server capabilities | -| `check_connection` | Test IMAP connectivity | -| `get_messages` | Paginated fetch, newest-first by UID | -| `search_messages` | IMAP SEARCH with text/header/flag filters | -| `list_flags` | List all flags in use with counts; resolves Apple Mail colors | -| `find_attachments` | Scan for messages with attachments | -| `top_senders` | Rank senders by message count | -| `top_domains` | Rank exact sender domains/subdomains with a live sample subject | -| `top_subscriptions` | Rank bulk-mail senders by List-Unsubscribe, sorted by one-click | -| `top_mailing_lists` | Rank mailing lists by List-Id (RFC 2919), groups across senders | -| `list_pending_moves` | Inspect durable COPY-fallback operations needing recovery/review | +| Tool | Description | +| ----------------------- | ---------------------------------------------------------------- | +| `list_accounts` | Configured accounts, the default, and each one's sending addresses | +| `list_identities` | Sending addresses: configured identities plus what Sent mail used | +| `list_mailboxes` | Selectable mailboxes with counts and special-use roles, paginated | +| `list_capabilities` | Query IMAP server capabilities | +| `check_connection` | Test IMAP connectivity | +| `get_messages` | Paginated fetch, newest-first by UID | +| `search_messages` | IMAP SEARCH with text/header/flag/date/size filters | +| `list_flags` | List all flags in use with counts; resolves Apple Mail colors | +| `find_attachments` | Scan for messages with attachments | +| `top_senders` | Rank senders by message count | +| `top_domains` | Rank exact sender domains/subdomains with a live sample subject | +| `top_subscriptions` | Rank bulk-mail senders by List-Unsubscribe, sorted by one-click | +| `top_mailing_lists` | Rank mailing lists by List-Id (RFC 2919), groups across senders | +| `list_pending_moves` | Inspect durable COPY-fallback operations needing recovery/review | +| `preview_thread_record` | Discover an exact Message-ID graph and return its digest | ### Write Operations -| Tool | Description | -| ---------------------- | ----------------------------------------------------------------- | -| `delete_messages` | Delete by UID (up to 500) | -| `delete_by_sender` | Delete all from a sender, optionally across all mailboxes | -| `delete_by_domain` | Delete all from one exact canonical sender domain | -| `delete_list_id` | Delete all messages with a specific List-Id across all mailboxes | -| `move_by_sender` | Move messages from an exact sender identity | -| `move_by_domain` | Move messages from one exact canonical sender domain | -| `move_list_id` | Move messages with an exact List-Id | -| `move_message` | IMAP MOVE between mailboxes | -| `reconcile_moves` | Safely resume durable COPY-fallback operations | -| `create_mailbox` | Create new folder | -| `create_draft` | Compose RFC822 → Drafts folder | -| `add_flags` | Add flags and/or Apple Mail color (union semantics) | -| `remove_flags` | Remove flags and/or clear Apple Mail color | -| `unsubscribe_message` | RFC 8058 one-click unsubscribe + bulk delete matching bulk mail | -| `download_attachments` | Extract attachments to disk | +| Tool | Description | +| ------------------------- | --------------------------------------------------------------- | +| `delete_messages` | Delete by UID (up to 500) | +| `delete_by_sender` | Delete all from a sender, optionally across all mailboxes | +| `delete_by_domain` | Delete all from one exact canonical sender domain | +| `delete_list_id` | Delete all messages with a specific List-Id | +| `move_by_sender` | Move messages from an exact sender identity | +| `move_by_domain` | Move messages from one exact canonical sender domain | +| `move_list_id` | Move messages with an exact List-Id | +| `move_subscription` | Move the bulk-mail subscription behind a `top_subscriptions` row | +| `move_message` | IMAP MOVE between mailboxes | +| `reconcile_moves` | Safely resume durable COPY-fallback operations | +| `create_mailbox` | Create new folder | +| `rename_mailbox` | Preview, then confirm a guarded rename | +| `delete_mailbox` | Preview, then confirm a guarded delete | +| `create_draft` | Compose RFC822 → Drafts, fresh or as a reply, From an identity | +| `update_draft` | Replace a draft (REPLACE or emulated), keeping its sender | +| `update_flags` | Add/remove flags and set or clear the Apple Mail color, one call | +| `unsubscribe_message` | RFC 8058 one-click unsubscribe, optional matching cleanup | +| `download_attachments` | Extract attachments to disk | | `download_message_source` | Save exact RFC822 bytes with integrity and DKIM evidence | -| `download_thread` | Save a caller-selected UID set plus a JSON evidence manifest | +| `download_thread` | Save a caller-selected UID set plus a JSON evidence manifest | +| `export_thread_record` | Confirm a preview digest; write PDF, EML and a manifest | ## MCP Prompts (6) @@ -265,7 +280,11 @@ within `1..=32`, an explicit default account must exist, and plaintext IMAP TOML before replacing it atomically, uses `0600` files on Unix, and disables terminal echo for password entry. A primary mailbox `email` is modelled separately from the login username, and `aliases` are canonicalized and -deduplicated for own-address comparisons; opaque IMAP usernames remain valid. +deduplicated; opaque IMAP usernames remain valid. The primary and the aliases +are the account's sending identities — the only addresses a draft may be +`From` — and `display_name` is the name drafts carry. A distinct login is a +credential, used to recognize the account's own mail but never sent as unless +it is also listed as an alias. ## Verification diff --git a/MCP.md b/MCP.md index 96dfee7..f2915a1 100644 --- a/MCP.md +++ b/MCP.md @@ -31,25 +31,55 @@ and complete on every `email://` resource template and every prompt. Call `list_accounts` only when the DEFAULT account matters, not to discover a selector. -## Tools (35) +## Tools (36) ### Discovery & Connection | # | Tool | Description | Annotations | | --- | ------------------- | ------------------------------------------------ | ----------- | -| 1 | `list_accounts` | Return configured IMAP account names | `read_only` | -| 2 | `list_mailboxes` | Paginate selectable folders with counts and registered special-use roles | `read_only` | -| 3 | `check_connection` | Test IMAP connectivity and auth for an account | `read_only` | -| 4 | `list_capabilities` | Query IMAP extensions (IDLE, MOVE, CONDSTORE) | `read_only` | +| 1 | `list_accounts` | Return configured IMAP account names, the default, and each account's sending addresses | `read_only` | +| 2 | `list_identities` | An account's sending addresses: configured ones plus the From addresses its recent Sent mail used | `read_only`, `idempotent` | +| 3 | `list_mailboxes` | Paginate selectable folders with counts and registered special-use roles | `read_only` | +| 4 | `check_connection` | Test IMAP connectivity and auth for an account | `read_only` | +| 5 | `list_capabilities` | Query IMAP extensions (IDLE, MOVE, CONDSTORE) | `read_only` | #### Output Schemas **list_accounts** → `ListAccountsResponse` ```json -{ "accounts": [{ "name", "isDefault": bool }] } +{ "accounts": [{ "name", "isDefault": bool, "displayName?", + "addresses": ["primary@example.com", "alias@example.com"] }] } ``` -The MCP projection intentionally omits IMAP hostnames and login usernames. +`addresses` are the account's **identities** — the addresses a draft may be +`From`: the primary (`email`, or an email-shaped login when no `email` is set) +first, then the aliases. `displayName` is the sender name drafts carry. The MCP +projection intentionally omits IMAP hostnames and login usernames; a login that +differs from the configured `email` is never listed unless it is also an alias. + +**list_identities** → `ListIdentitiesResponse` +```json +{ "account", "displayName?", "sentMailbox": "Sent" | null, "scannedMessages", + "identities": [{ "address", "configured": bool, "primary": bool, + "displayNames": [], "messages", "lastUsed?" }] } +``` + +IMAP has no identity list (Gmail's send-as and JMAP `Identity` need API +credentials an app-password account does not hold), so this reports the +configured identities plus what the account has actually SENT: the From +addresses among the newest `sentMessages` (default 200, `1..=1000`) messages of +the selectable `\Sent` mailbox — exact names (`Sent`, `Sent Items`, `Sent +Messages`, `Sent Mail`, `[Gmail]/Sent Mail`, `INBOX.Sent`) when the server +declares no role. The read is `EXAMINE` plus `BODY.PEEK`, so nothing is marked +read. + +Configured identities come first (primary, then aliases) and appear even with +`messages: 0`; addresses seen only in Sent follow, most used first, at most 50 +rows and five display names each (most used first). With no Sent mailbox, +`sentMailbox` is `null` and the configured identities are still returned — not +an error. A discovered address (`configured: false`) is evidence, not +permission: `from` accepts it only after it is added to the account as an +alias. **list_mailboxes** → `ListMailboxesResponse` ```json @@ -89,16 +119,16 @@ an unknown account raises `-32602`. | # | Tool | Description | Annotations | | --- | ------------------ | --------------------------------------------------------------------------------------------------- | ---------------------- | -| 5 | `get_messages` | Paginated metadata discovery from one required mailbox, newest-first. Default: offset=0, limit=25 (max 50) | `read_only` | -| 6 | `search_messages` | Paginated IMAP metadata search of one required mailbox with text, headers, status, date, and size filters. | `read_only` | -| 7 | `list_flags` | All IMAP flags in use with counts. Resolves Apple $MailFlagBit colors. Omit mailbox to scan all. | `read_only`, `taskable` | -| 8 | `find_attachments` | Scan for messages with attachments (mixed + related), paginated. Omit mailbox to scan all. | `read_only`, `taskable` | -| 9 | `top_senders` | Top senders by volume (email, display name) with counts + date ranges. Omit mailbox to scan all. | `read_only`, `taskable` | -| 10 | `top_subscriptions` | Top bulk-mail senders with advertised one-click syntax and UIDVALIDITY-guarded samples. | `read_only`, `taskable` | -| 11 | `top_mailing_lists` | Top mailing lists by List-Id (RFC 2919). Groups across senders. Omit mailbox to scan all. | `read_only`, `taskable` | -| 12 | `top_domains` | Exact canonical Header From domains and subdomains with counts, dates, and a live sample subject. | `read_only`, `taskable` | -| 13 | `list_pending_moves` | List durable COPY-fallback MOVE operations awaiting reconciliation or review. | `read_only` | -| 14 | `preview_thread_record` | Discover a bounded exact Message-ID graph and return a confirmation digest without writing files. | `read_only`, `taskable` | +| 6 | `get_messages` | Paginated metadata discovery from one required mailbox, newest-first. Default: offset=0, limit=25 (max 50) | `read_only` | +| 7 | `search_messages` | Paginated IMAP metadata search of one required mailbox with text, headers, status, date, and size filters. | `read_only` | +| 8 | `list_flags` | All IMAP flags in use with counts. Resolves Apple $MailFlagBit colors. Omit mailbox to scan all. | `read_only`, `taskable` | +| 9 | `find_attachments` | Scan for messages with attachments (mixed + related), paginated. Omit mailbox to scan all. | `read_only`, `taskable` | +| 10 | `top_senders` | Top senders by volume (email, display name) with counts + date ranges. Omit mailbox to scan all. | `read_only`, `taskable` | +| 11 | `top_subscriptions` | Top bulk-mail senders with advertised one-click syntax and UIDVALIDITY-guarded samples. | `read_only`, `taskable` | +| 12 | `top_mailing_lists` | Top mailing lists by List-Id (RFC 2919). Groups across senders. Omit mailbox to scan all. | `read_only`, `taskable` | +| 13 | `top_domains` | Exact canonical Header From domains and subdomains with counts, dates, and a live sample subject. | `read_only`, `taskable` | +| 14 | `list_pending_moves` | List durable COPY-fallback MOVE operations awaiting reconciliation or review. | `read_only` | +| 15 | `preview_thread_record` | Discover a bounded exact Message-ID graph and return a confirmation digest without writing files. | `read_only`, `taskable` | #### Output Schemas @@ -292,23 +322,23 @@ shown by the preview to a later `export_thread_record` confirmation. | # | Tool | Description | Annotations | | --- | ---------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------- | -| 15 | `delete_messages` | Delete by UID (up to 500). Moves to Trash, or permanently expunges when `permanent=true`. | `destructive`, `idempotent`, `taskable` | -| 16 | `delete_by_sender` | Delete all from an exact sender identity (`email` + `name` from a ranking row). Omit `mailbox` for account-wide. `permanent=true` bypasses Trash. | `destructive`, `taskable` | -| 17 | `delete_list_id` | Delete all messages with an **exact** List-Id across all mailboxes. `permanent=true` bypasses Trash. | `destructive`, `taskable` | -| 18 | `delete_by_domain` | Delete all messages from one exact canonical domain from `top_domains`; subdomains are never implicit. | `destructive`, `taskable` | -| 19 | `move_list_id` | Move all messages with an **exact** List-Id to a destination mailbox in one operation (e.g. archive a statement list). Omit `mailbox` for account-wide; destination excluded. | `taskable` | -| 20 | `move_by_sender` | Move all messages from an exact sender identity (`email` + `name`) to a destination mailbox in one operation. Omit `mailbox` for account-wide; destination excluded. | `taskable` | -| 21 | `move_by_domain` | Move all messages from one exact canonical domain to a destination; subdomains are never implicit. | `taskable` | -| 22 | `move_subscription` | Move the exact bulk-mail subscription represented by a UIDVALIDITY-safe `top_subscriptions` sample; destination excluded. | `taskable` | -| 23 | `move_message` | IMAP MOVE between mailboxes (durable COPY+EXPUNGE fallback when MOVE is unavailable). | | -| 24 | `reconcile_moves` | Safely resume one or all pending COPY-fallback MOVE operations. | `destructive`, `taskable` | -| 25 | `create_mailbox` | Create new folder | `idempotent` | -| 26 | `rename_mailbox` | Preview, then confirm a guarded mailbox rename. | `destructive` | -| 27 | `delete_mailbox` | Preview, then confirm guarded mailbox deletion. | `destructive`, `idempotent` | -| 28 | `create_draft` | Save an RFC822 draft — fresh, or a reply derived from a live message via `replyToMessage`. | | -| 29 | `update_draft` | Replace a live draft: RFC 8508 REPLACE where the server has it, APPEND-then-discard otherwise. | `destructive` | -| 30 | `download_attachments` | Extract attachments to disk as `{uid}_{index}_{filename}` | `taskable` | -| 31 | `download_message_source` | Save exact RFC822 bytes directly to disk with SHA-256, metadata, and local DNS-backed DKIM evidence. | `open_world`, `taskable` | +| 16 | `delete_messages` | Delete by UID (up to 500). Moves to Trash, or permanently expunges when `permanent=true`. | `destructive`, `idempotent`, `taskable` | +| 17 | `delete_by_sender` | Delete all from an exact sender identity (`email` + `name` from a ranking row). Omit `mailbox` for account-wide. `permanent=true` bypasses Trash. | `destructive`, `taskable` | +| 18 | `delete_list_id` | Delete all messages with an **exact** List-Id across all mailboxes. `permanent=true` bypasses Trash. | `destructive`, `taskable` | +| 19 | `delete_by_domain` | Delete all messages from one exact canonical domain from `top_domains`; subdomains are never implicit. | `destructive`, `taskable` | +| 20 | `move_list_id` | Move all messages with an **exact** List-Id to a destination mailbox in one operation (e.g. archive a statement list). Omit `mailbox` for account-wide; destination excluded. | `taskable` | +| 21 | `move_by_sender` | Move all messages from an exact sender identity (`email` + `name`) to a destination mailbox in one operation. Omit `mailbox` for account-wide; destination excluded. | `taskable` | +| 22 | `move_by_domain` | Move all messages from one exact canonical domain to a destination; subdomains are never implicit. | `taskable` | +| 23 | `move_subscription` | Move the exact bulk-mail subscription represented by a UIDVALIDITY-safe `top_subscriptions` sample; destination excluded. | `taskable` | +| 24 | `move_message` | IMAP MOVE between mailboxes (durable COPY+EXPUNGE fallback when MOVE is unavailable). | | +| 25 | `reconcile_moves` | Safely resume one or all pending COPY-fallback MOVE operations. | `destructive`, `taskable` | +| 26 | `create_mailbox` | Create new folder | `idempotent` | +| 27 | `rename_mailbox` | Preview, then confirm a guarded mailbox rename. | `destructive` | +| 28 | `delete_mailbox` | Preview, then confirm guarded mailbox deletion. | `destructive`, `idempotent` | +| 29 | `create_draft` | Save an RFC822 draft — fresh, or a reply derived from a live message via `replyToMessage`. | | +| 30 | `update_draft` | Replace a live draft: RFC 8508 REPLACE where the server has it, APPEND-then-discard otherwise. | `destructive` | +| 31 | `download_attachments` | Extract attachments to disk as `{uid}_{index}_{filename}` | `taskable` | +| 32 | `download_message_source` | Save exact RFC822 bytes directly to disk with SHA-256, metadata, and local DNS-backed DKIM evidence. | `open_world`, `taskable` | | 33 | `download_thread` | Save a caller-selected set of up to 100 UIDs plus a JSON evidence manifest. Does not discover thread membership. | `open_world`, `taskable` | | 34 | `export_thread_record` | Confirm a preview digest and write PDF, exact EML sources, and an integrity manifest. | `open_world`, `taskable` | | 35 | `unsubscribe_message` | DKIM-verified RFC 8058 POST; optional matching-message cleanup via the nested `cleanup {when, identity, deletion}` object (omitted = unsubscribe only). | `destructive`, `open_world`, `taskable` | @@ -529,11 +559,27 @@ reports success only when the resulting state is unambiguous. **create_draft** ```json -{ "created": true, "account", "draftsMailbox", "attachmentCount", +{ "created": true, "account", "draftsMailbox", "from", "attachmentCount", "replyToCount", "threadingApplied", "warning?", "uidValidity?", "uid?" } ``` +**The sender.** A draft is `From` one of the account's identities (the +`addresses` `list_accounts` reports), under the configured display name: + +1. `from` — `Name
` or a bare address — must be one of the identities, + or the call is refused with the list of them. A name given with it replaces + the display name. +2. A reply (`replyToMessage`) is From the identity the original involved: its + own `From` for a follow-up to this account's mail, else the first identity + in its `To`, then `Cc`. +3. Otherwise the primary identity. + +An account with no identity at all (an opaque login and no `email`) is refused +with what to configure. The result's `from` is the sender actually written — +resolved, not echoed — formatted so it parses back as one mailbox (a name with a +comma or other special is quoted). + Draft bodies are written in **Markdown** and sent as `multipart/alternative` (RFC 2046 §5.1.4): the text exactly as written as `text/plain`, then an HTML rendering of the same source, least-preferred part first so every client picks @@ -589,14 +635,18 @@ reply goes to the wrong people. This tool never sends mail. **update_draft** ```json -{ "updated": true, "account", "draftsMailbox", +{ "updated": true, "account", "draftsMailbox", "from", "previousUidValidity", "previousUid", "uidValidity?", "uid?", "warning?" } ``` The input is a complete replacement specification, including attachments. AgentMail verifies the live UIDVALIDITY and `\Draft` flag and preserves the -Apple draft UUID. +Apple draft UUID. It also preserves the draft's sender: without `from`, the +replacement keeps the current `From` — name included, the display name filling +a blank one — when that address is one of the account's identities, so a sender +chosen in another mail client survives an edit here; a foreign `From` is +replaced by the primary identity. `from` follows the `create_draft` rules. Where the server advertises RFC 8508 REPLACE the swap is one atomic command. Where it does not — Gmail and iCloud among them — AgentMail emulates it as @@ -747,7 +797,7 @@ message ceiling and continues to mutate in 500-UID batches. | # | Tool | Description | Annotations | | --- | -------------- | ------------------------------------------------------------------------------------ | ----------- | -| 35 | `update_flags` | Add flags, remove flags, and set or clear the Apple Mail `color` in ONE call. Colors: red, orange, yellow, green, blue, purple, gray, or `none` to clear. Unnamed flags are untouched. | `idempotent` | +| 36 | `update_flags` | Add flags, remove flags, and set or clear the Apple Mail `color` in ONE call. Colors: red, orange, yellow, green, blue, purple, gray, or `none` to clear. Unnamed flags are untouched. | `idempotent` | Adding and removing were two tools. Doing both then meant two calls across two UIDVALIDITY windows, so the second could be refused with the first already diff --git a/README.md b/README.md index 57581e0..d88a582 100644 --- a/README.md +++ b/README.md @@ -30,14 +30,18 @@ Output binary: `target/release/agentmail` ## Configuration -agentmail reads its config from a single TOML file: +agentmail reads its config from a single TOML file, at the platform's config +directory unless `AGENTMAIL_CONFIG` names another path: -| Location | Path | +| Platform | Default path | | -------- | ----------------------------------------------------------- | -| Default | `~/.config/agentmail/config.toml` | -| Override | Set the `AGENTMAIL_CONFIG` environment variable to any path | +| macOS | `~/Library/Application Support/agentmail/config.toml` | +| Linux | `$XDG_CONFIG_HOME/agentmail/config.toml` (`~/.config/…`) | +| Windows | `%APPDATA%\agentmail\config.toml` | +| Any | Set the `AGENTMAIL_CONFIG` environment variable to any path | -On macOS the default expands to `~/Library/Application Support/agentmail/config.toml` if `dirs::config_dir()` returns `Library/Application Support`, but `~/.config/agentmail/config.toml` is more conventional and works fine — just pick one. +On macOS, `~/.config/agentmail/config.toml` is NOT read. To keep the file there, +point `AGENTMAIL_CONFIG` at it. ### Quick start @@ -79,6 +83,7 @@ host = "imap.mail.me.com" username = "johnappleseed" email = "john@example.com" aliases = ["john@icloud.com", "john@me.com"] +display_name = "John Appleseed" password.cmd = "security find-internet-password -s imap.mail.me.com -a johnappleseed -w" [accounts.work] @@ -96,8 +101,9 @@ All accounts are available simultaneously — the MCP tools and CLI commands acc | `host` | string | **required** | Non-empty IMAP hostname or IP address | | `port` | u16 | `993` | IMAP port; must be non-zero | | `username` | string | **required** | Non-empty login username / email | -| `email` | string | — | Primary mailbox address when it differs from the login | -| `aliases` | string[] | `[]` | Additional own addresses, canonicalized and deduplicated | +| `email` | string | — | Primary mailbox address when it differs from the login; a draft's default `From` | +| `aliases` | string[] | `[]` | Other addresses this account sends as, canonicalized and deduplicated | +| `display_name` | string | — | Sender name on drafts: `Mark Blake `. No control characters, at most 256 characters | | `password` | Secret | — | Password source (see [Passwords](#passwords) below) | | `tls` | bool | `true` | TLS is mandatory; `false` is rejected | | `max_connections` | usize | provider-specific | `1..=32`; defaults to 1 for Yahoo/AOL and 3 otherwise | @@ -110,6 +116,30 @@ email-shaped login usernames are canonicalized for own-address comparisons; opaque IMAP login names remain valid. Set `email` for providers such as iCloud when the login name is not an address, and list any delivery aliases separately. +### Sender identity + +A draft is `From` one of the account's **identities**: the primary address +(`email`, or an email-shaped `username` when `email` is unset), then each alias. +`display_name` is the name shown with it, so `display_name = "Mark Blake"` makes +drafts `From: Mark Blake `. + +- **Default:** the primary address, under `display_name`. +- **`from`:** `create_draft` and `update_draft` (and the CLI's `create-draft + --from`) take `Name
` or a bare address. The address must be one of + the identities; anything else is refused with the list of them. Add an + address as an alias to send as it. +- **Replies** are `From` the identity the original involved: its own `From` for + a follow-up to your own mail, else the first identity in its `To`, then `Cc`. +- **`update_draft`** keeps the draft's current `From` — name included — when it + is one of the identities, so a sender chosen in another mail client survives. +- An account with no identity at all (an opaque login and no `email`) cannot + create drafts until `email` is set. + +A distinct email-shaped login is used to recognize your own mail but is never a +sending identity unless you list it in `aliases`. `list_accounts` reports each +account's identities; `list_identities` adds the addresses its recent Sent mail +actually went out from. + Trash and drafts mailboxes are auto-detected at runtime via RFC 6154 special-use attributes (`\Trash`, `\Drafts`), with string-matching fallback for servers that don't support RFC 6154. ### Passwords @@ -362,6 +392,7 @@ agentmail download-attachments --account gmail --mailbox INBOX --uid 123 --expec agentmail list-flags --account gmail agentmail add-flags --account gmail --mailbox INBOX --uid 123 --expected-uid-validity 3857529045 --flags "\\Seen" --color red agentmail create-draft --account gmail --subject "Hello" --body "Hi there" --to user@example.com +agentmail create-draft --account gmail --from "Mark Blake " --subject "Hello" --body "Hi" --to user@example.com agentmail list-pending-moves --account gmail agentmail reconcile-moves --account gmail --operation-id ``` @@ -409,14 +440,15 @@ Opens a web UI to exercise all advertised tools, 6 prompts, and task calls inter ## MCP Tools -37 tools cover account discovery, mailbox management, message reading, search, +36 tools cover account discovery, mailbox management, message reading, search, bulk operations, recovery, evidence archiving, flag management, and composition. AgentMail saves drafts but never sends mail. 22 long-running tools support optional [task-based invocation](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/tasks) (SEP-1686) for asynchronous execution. | Tool | Description | | ---------------------- | ------------------------------------------------------------------------------------- | -| `list_accounts` | Return configured account names (use this first) | +| `list_accounts` | Return configured account names, the default, and each account's sending addresses | +| `list_identities` | An account's sending addresses: configured ones plus those its recent Sent mail used | | `list_mailboxes` | Paginate selectable mailboxes with counts and registered special-use roles | | `create_mailbox` | Create a new mailbox (folder) on the server | | `rename_mailbox` | Preview, then confirm a guarded mailbox rename | @@ -454,9 +486,10 @@ optional [task-based invocation](https://modelcontextprotocol.io/specification/2 ### Key parameters -- `account` is **required** for most tools, including `list_mailboxes`. Use - `list_accounts` to discover valid names. MCP account discovery returns names - and default status, not IMAP hosts or usernames. +- `account` is **required** for most tools, including `list_mailboxes`. The + configured names are in every `account` argument's schema. `list_accounts` + returns names, default status, the display name and the addresses each + account sends as — never IMAP hosts or login usernames. - `mailbox` is required for single-mailbox readers and every UID consumer. It may be omitted only on account-wide tools such as `top_*`, `list_flags`, and `find_attachments`. Discovery uses one selectable server-declared `\All` @@ -555,7 +588,9 @@ optional [task-based invocation](https://modelcontextprotocol.io/specification/2 - Non-ASCII `search_messages` text is sent with `CHARSET UTF-8`. Drafts include `Date`, `Message-ID`, and Apple Mail draft markers. Bcc is retained in the saved draft, Reply-To is supported, and reply drafts use RFC - `In-Reply-To`/`References` headers. Draft bodies are Markdown and ship as + `In-Reply-To`/`References` headers. A draft is `From` one of the account's + identities (see [Sender identity](#sender-identity)); `from` picks another, + and the result reports the sender written. Draft bodies are Markdown and ship as `multipart/alternative` — the source verbatim as `text/plain` plus an HTML rendering — so formatting arrives rendered rather than as literal syntax; raw HTML in a body is escaped, never rendered, and `plainTextOnly: true` @@ -724,7 +759,7 @@ Argument autocompletion (`completion/complete`) is supported for the prompts and ``` agentmail (binary crate: agentmail-mcp) ├── serve → MCP stdio server (tokio + rmcp) - │ 37 tools + 6 prompts, tasks, progress notifications + │ 36 tools + 6 prompts, tasks, progress notifications ├── list-accounts → CLI ├── list-mailboxes → CLI ├── create-mailbox → CLI @@ -748,7 +783,7 @@ agentmail (binary crate: agentmail-mcp) src/ (library + binary) ├── lib.rs → Public API facade (25+ async methods) ├── main.rs → CLI dispatch (clap), account configuration - ├── mcp/ → MCP server: 37 tools, 6 prompts, tasks, resources, completions + ├── mcp/ → MCP server: 36 tools, 6 prompts, tasks, resources, completions ├── config.rs → TOML config loading, default account resolution ├── credentials.rs → Password resolution (env → config secret → default keyring) ├── connection.rs → IMAP connection pool (provider-aware per-account cap) diff --git a/docs/patches/async-imap-surface-tagged-status.md b/docs/patches/async-imap-surface-tagged-status.md index dd80260..0c30847 100644 --- a/docs/patches/async-imap-surface-tagged-status.md +++ b/docs/patches/async-imap-surface-tagged-status.md @@ -1,4 +1,11 @@ -# Patch: surface tagged NO/BAD on SEARCH and FETCH (async-imap 0.11.3) +# Patch: surface tagged NO/BAD on SEARCH and FETCH (async-imap 0.11.3, 0.12.0) + +Status: NOT applied. Re-checked against async-imap 0.12.0 (2026-10-06): the +release moved to imap-proto 0.17 and fixed LOGIN, but `parse_ids` and +`parse_fetches` still drain with `take_while(filter)`, so the gap below is +unchanged and agentmail's guards stay load-bearing. In 0.12 the tagged +response's code and text moved into `Response::Done { outcome, .. }` +(`outcome.code`, `outcome.information`); the sketch below is written for 0.12. ## The bug @@ -46,8 +53,9 @@ pub(crate) async fn parse_ids> + Unpin while let Some(resp) = stream.try_next().await? { match resp.parsed() { - Response::Done { tag, status, code, information, .. } if tag == &command_tag => { + Response::Done { tag, status, outcome } if tag == &command_tag => { use imap_proto::Status; + let (code, information) = (&outcome.code, &outcome.information); match status { Status::Ok => break, Status::Bad => return Err(Error::Bad(format!("code: {code:?}, info: {information:?}"))), diff --git a/docs/patches/imap-proto-uidfetch.md b/docs/patches/imap-proto-uidfetch.md index 7035269..ad9cb13 100644 --- a/docs/patches/imap-proto-uidfetch.md +++ b/docs/patches/imap-proto-uidfetch.md @@ -1,18 +1,26 @@ # Patch: imap-proto UIDFETCH response parsing (RFC 9586) -Status: applied as a local vendored fork at `vendor/imap-proto`, wired via -`[patch.crates-io]` in the root `Cargo.toml`. Upstream when possible to drop -the fork. +Status: applied as a local vendored fork of **imap-proto 0.17.0** at +`vendor/imap-proto`, wired via `[patch.crates-io]` in the root `Cargo.toml`. +Upstream when possible to drop the fork. + +History: first applied to 0.16.7 (async-imap 0.11). Rebased onto pristine +0.17.0 for async-imap 0.12 — 0.17 moved to nom 8 (combinators are `Parser`s +driven by `.parse(i)`, and `tuple((…))` is a bare tuple) and flattened the +source tree (`src/parser/rfc3501/` → `src/rfc3501/`). 0.17.0 still has no +UIDFETCH parser, so the patch is the same function in the new idiom. The +vendored tree is the published crate's `src/`, `examples/`, manifest, lockfile +and licences; that function and its `alt` entry are the only edits. ## Why Yahoo/AOL (and any RFC 9586 server) expose the **entire** mailbox only in UID Mode, entered with `ENABLE UIDONLY`. In UID Mode the server replies to fetches with `* UIDFETCH (…)` instead of `* FETCH (…)`. Stock -imap-proto 0.16.7 has no `UIDFETCH` parser, so async-imap's `parse_fetches` -would fail on every response — which is why agentmail is stuck in Limited -Mode behind the visible-window limit (10k/100k), working around it with -windowed search + delete-drain loops. +imap-proto (0.16.7 and 0.17.0 alike) has no `UIDFETCH` parser, so +async-imap's `parse_fetches` would fail on every response — which is why +agentmail is stuck in Limited Mode behind the visible-window limit +(10k/100k), working around it with windowed search + delete-drain loops. This patch unblocks UID Mode. `ENABLE` support already exists (`imap_client::enable`); the remaining work is wiring `ENABLE UIDONLY` + @@ -21,7 +29,8 @@ change that needs real-server probe validation before shipping). ## The change -`vendor/imap-proto/src/parser/rfc3501/mod.rs`, mirroring `message_data_fetch`: +`vendor/imap-proto/src/rfc3501/mod.rs`, mirroring 0.17's `message_data_fetch` +(marked `[agentmail local patch — see docs/patches/]` in the source): ```rust // UIDFETCH (RFC 9586): identical to FETCH, but the leading number is the UID @@ -29,14 +38,15 @@ change that needs real-server probe validation before shipping). // synthesized Uid attribute so existing consumers and `.uid()` work unchanged. fn message_data_uidfetch(i: &[u8]) -> IResult<&[u8], Response<'_>> { map( - tuple((number, tag_no_case(" UIDFETCH "), msg_att_list)), + (number, tag_no_case(" UIDFETCH "), msg_att_list), |(uid, _, mut attrs)| { if !attrs.iter().any(|attr| matches!(attr, AttributeValue::Uid(_))) { attrs.insert(0, AttributeValue::Uid(uid)); } Response::Fetch(uid, attrs) }, - )(i) + ) + .parse(i) } ``` @@ -52,7 +62,21 @@ even when the server omits it (which UID Mode permits). `imap_client::tests::uidfetch_response_is_parsed_with_synthesized_uid` scripts a server replying `* 42 UIDFETCH (RFC822.SIZE 5)` (UID omitted) and asserts the fetched item's UID is `Some(42)` — proving the leading number is surfaced -through async-imap unchanged. +through async-imap unchanged. Two parser-level tests pin the shapes AOL +actually sends, through `imap_proto::Response::parse` (0.17's entry point; +0.16's was `parser::parse_response`): +`uidonly_uidfetch_line_parses_via_the_patched_imap_proto` (`UIDFETCH (UID n)`) +and `uidfetch_with_a_body_section_literal_parses_via_the_patched_imap_proto` +(a `BODY[HEADER.FIELDS …]` literal inside a UIDFETCH). + +## Re-vendoring + +To move to a new imap-proto release: copy the published crate (not a git +checkout — the `.cargo_vcs_info.json` records which commit it was built from) +over `vendor/imap-proto`, re-apply `message_data_uidfetch` plus its `alt` entry +in `response_data` right after `message_data_fetch`, and run the three tests +above. `examples/probe_uidonly.rs` against a real Yahoo/AOL account is the +end-to-end check. ## Upstreaming diff --git a/examples/probe_capabilities.rs b/examples/probe_capabilities.rs index 9225a4a..0bcf182 100644 --- a/examples/probe_capabilities.rs +++ b/examples/probe_capabilities.rs @@ -31,6 +31,7 @@ async fn main() -> Result<(), Box> { username: user, email: None, aliases: Vec::new(), + display_name: None, password: Some(Secret::new_raw(&pass)), tls: true, max_connections: None, diff --git a/examples/probe_header_fields.rs b/examples/probe_header_fields.rs index e42343c..3baca1c 100644 --- a/examples/probe_header_fields.rs +++ b/examples/probe_header_fields.rs @@ -84,6 +84,7 @@ async fn main() -> Result<(), Box> { username: user, email: None, aliases: Vec::new(), + display_name: None, password: Some(Secret::new_raw(&pass)), tls: true, max_connections: None, diff --git a/examples/probe_uid_window.rs b/examples/probe_uid_window.rs index 4a14fda..e4b777b 100644 --- a/examples/probe_uid_window.rs +++ b/examples/probe_uid_window.rs @@ -38,6 +38,7 @@ async fn main() -> Result<(), Box> { username: user, email: None, aliases: Vec::new(), + display_name: None, password: Some(Secret::new_raw(&pass)), tls: true, max_connections: None, diff --git a/examples/probe_uidonly.rs b/examples/probe_uidonly.rs index d424057..857ffb7 100644 --- a/examples/probe_uidonly.rs +++ b/examples/probe_uidonly.rs @@ -59,10 +59,9 @@ async fn raw_uid_fetch( Response::Done { tag: done, status, - information, - .. + outcome, } if done == &tag => { - let text = format!("{status:?} {information:?}"); + let text = format!("{status:?} {:?}", outcome.information); if !matches!(status, Status::Ok) { return Ok((rows, text)); // report NO/BAD without erroring } @@ -158,6 +157,7 @@ async fn main() -> Result<(), Box> { username: user, email: None, aliases: Vec::new(), + display_name: None, password: Some(Secret::new_raw(&pass)), tls: true, max_connections: None, diff --git a/src/config.rs b/src/config.rs index 9ecdae0..2969681 100644 --- a/src/config.rs +++ b/src/config.rs @@ -38,12 +38,20 @@ pub struct AccountConfig { pub port: u16, pub username: String, /// Primary mailbox address when the IMAP login is not itself an email - /// address. Used for own-address filtering, never for authentication. + /// address. A draft's default `From`, and an own address for filtering; + /// never used for authentication. #[serde(default)] pub email: Option, - /// Additional mailbox identities used for own-address filtering. + /// Additional addresses this account sends as. Each is an identity a draft + /// may be `From` (see [`Self::identities`]) and an own address for + /// filtering. #[serde(default)] pub aliases: Vec, + /// The sender's name on drafts: `display_name = "Mark Blake"` makes a + /// draft `From: Mark Blake `. Unset, `From` is the bare + /// address. + #[serde(default)] + pub display_name: Option, /// Password secret: `password.raw = "..."`, `password.cmd = "..."`, /// or `password.keyring = "..."`. Legacy `password = "..."` is also /// accepted. Under `auth = "xoauth2"` this yields the OAuth access token. @@ -74,6 +82,7 @@ impl AccountConfig { username, email: None, aliases: Vec::new(), + display_name: None, password, tls: true, max_connections: None, @@ -105,7 +114,7 @@ impl AccountConfig { self } - /// Set additional mailbox identities used for own-address filtering. + /// Set the additional addresses this account sends as. pub fn with_aliases(mut self, aliases: I) -> Self where I: IntoIterator, @@ -115,6 +124,12 @@ impl AccountConfig { self } + /// Set the sender's name shown on drafts (`Name
`). + pub fn with_display_name(mut self, display_name: impl Into) -> Self { + self.display_name = Some(display_name.into()); + self + } + /// Return the configured primary address, falling back to an email-shaped /// login username. /// @@ -127,9 +142,34 @@ impl AccountConfig { .or_else(|| canonicalize_email(&self.username)) } + /// The addresses this account sends as, canonical and deduplicated: the + /// primary ([`Self::canonical_email`]) first, then the aliases. + /// + /// This is the set a draft's `From` must come from and the set + /// `list_accounts` advertises. It is narrower than + /// [`Self::canonical_addresses`] on purpose: an email-shaped login that + /// differs from a configured `email` is a credential, not an address the + /// user chose to send as, so it is never offered — list it in `aliases` to + /// make it one. + pub fn identities(&self) -> Vec { + let mut identities = Vec::with_capacity(self.aliases.len().saturating_add(1)); + if let Some(primary) = self.canonical_email() { + identities.push(primary); + } + for alias in &self.aliases { + if let Some(alias) = canonicalize_email(alias) + && !identities.contains(&alias) + { + identities.push(alias); + } + } + identities + } + /// Return every configured mailbox identity in canonical, deduplicated /// form. The primary identity is first, followed by an email-shaped login - /// (when distinct) and then aliases. + /// (when distinct) and then aliases. A superset of [`Self::identities`], + /// used only to recognize the account's own mail, never to send as. pub fn canonical_addresses(&self) -> Vec { let mut addresses = Vec::with_capacity(self.aliases.len().saturating_add(2)); if let Some(primary) = self.canonical_email() { @@ -161,14 +201,18 @@ impl AccountConfig { let trimmed = alias.trim(); *alias = canonicalize_email(trimmed).unwrap_or_else(|| trimmed.to_string()); } + // An alias equal to a distinct login is KEPT: listing it is how the + // login address becomes one this account may send as. let primary = self.canonical_email(); - let login = canonicalize_email(&self.username); let mut seen = hashbrown::HashSet::with_capacity(self.aliases.len()); - self.aliases.retain(|alias| { - primary.as_ref() != Some(alias) - && login.as_ref() != Some(alias) - && seen.insert(alias.clone()) - }); + self.aliases + .retain(|alias| primary.as_ref() != Some(alias) && seen.insert(alias.clone())); + self.display_name = self + .display_name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string); } fn validate(&self, account_name: &str) -> crate::Result<()> { @@ -210,6 +254,12 @@ impl AccountConfig { "aliases must contain only valid bare email addresses", )); } + if let Some(problem) = self.display_name.as_deref().and_then(display_name_problem) { + return Err(config_error( + account_name, + &format!("display_name {problem}"), + )); + } if self.port == 0 { return Err(config_error( account_name, @@ -238,6 +288,41 @@ fn config_error(account_name: &str, message: &str) -> crate::AgentmailError { crate::AgentmailError::Config(format!("account '{account_name}': {message}")) } +/// Longest accepted sender display name, in characters. +pub const MAX_DISPLAY_NAME_CHARS: usize = 256; + +/// Check a sender display name — the `Mark Blake` of `Mark Blake ` — +/// before it reaches a `From` header. +/// +/// Control characters are refused: CR or LF would end the header early (and +/// make lettre's `Mailbox` formatting fail outright), and no other control +/// character belongs in a name a person reads. Quotes, commas, `@` and +/// non-ASCII letters are all fine; they are quoted or RFC 2047-encoded when +/// the header is written. Exported so an embedding app validates its settings +/// with the same rule the server applies. +pub fn validate_display_name(name: &str) -> crate::Result<()> { + match display_name_problem(name) { + Some(problem) => Err(crate::AgentmailError::Config(format!( + "display_name {problem}" + ))), + None => Ok(()), + } +} + +/// What is wrong with a sender name, phrased to follow the name of whatever +/// field held it ("display_name must …"); `None` when it is usable. +pub(crate) fn display_name_problem(name: &str) -> Option { + if name.chars().any(char::is_control) { + Some("must not contain control characters (line breaks included)".to_string()) + } else if name.chars().count() > MAX_DISPLAY_NAME_CHARS { + Some(format!( + "must be at most {MAX_DISPLAY_NAME_CHARS} characters" + )) + } else { + None + } +} + fn normalize_host(host: &str) -> String { host.trim().trim_end_matches('.').to_ascii_lowercase() } @@ -430,7 +515,10 @@ impl Config { } } - /// Default config path: `$AGENTMAIL_CONFIG` or `~/.config/agentmail/config.toml`. + /// Default config path: `$AGENTMAIL_CONFIG`, else `agentmail/config.toml` + /// under the platform config directory — `~/Library/Application Support` + /// on macOS (where `~/.config` is NOT read), `$XDG_CONFIG_HOME` or + /// `~/.config` on Linux, `%APPDATA%` on Windows. pub fn default_path() -> PathBuf { if let Ok(p) = std::env::var("AGENTMAIL_CONFIG") { return PathBuf::from(p); @@ -598,6 +686,98 @@ mod tests { } } + #[test] + fn display_name_is_trimmed_and_a_blank_one_is_unset() { + let mut config: Config = ::toml::from_str( + r#" + [accounts.work] + host = "imap.example.com" + username = "me@example.com" + display_name = " Mark Blake " + + [accounts.blank] + host = "imap.example.com" + username = "other@example.com" + display_name = " " + "#, + ) + .expect("valid TOML"); + + config.normalize_and_validate().expect("valid config"); + + assert_eq!( + config.accounts["work"].display_name.as_deref(), + Some("Mark Blake") + ); + assert_eq!(config.accounts["blank"].display_name, None); + } + + #[test] + fn a_display_name_with_a_control_character_is_rejected() { + // TOML escapes, so the parsed value carries the raw control character. + for name in [r"Mark\r\nBcc: x@evil.example", r"Mark\u0007"] { + let input = format!( + "[accounts.bad]\nhost = \"imap.example.com\"\nusername = \"me@example.com\"\ndisplay_name = \"{name}\"\n" + ); + let mut config: Config = ::toml::from_str(&input).expect("valid TOML shape"); + let error = config + .normalize_and_validate() + .expect_err("a control character must be rejected") + .to_string(); + assert!(error.contains("control characters"), "{error}"); + } + assert!(validate_display_name("Blake, Mark \"Shark\" Jos\u{e9}").is_ok()); + assert!(validate_display_name(&"x".repeat(MAX_DISPLAY_NAME_CHARS)).is_ok()); + assert!(validate_display_name(&"x".repeat(MAX_DISPLAY_NAME_CHARS + 1)).is_err()); + } + + /// The sendable set is the primary and the aliases. A login that differs + /// from the configured `email` stays a filter-only own address unless the + /// user lists it as an alias. + #[test] + fn identities_are_the_primary_then_aliases_never_a_distinct_login() { + let mut config: Config = ::toml::from_str( + r#" + [accounts.corp] + host = "imap.example.com" + username = "login@example.com" + email = "Mark@Example.com" + aliases = ["sales@example.com"] + + [accounts.listed] + host = "imap.example.com" + username = "login@example.com" + email = "mark@example.com" + aliases = ["LOGIN@example.com"] + + [accounts.gmail] + host = "imap.gmail.com" + username = "Me@Gmail.com" + + [accounts.icloud] + host = "imap.mail.me.com" + username = "johnappleseed" + "#, + ) + .expect("valid TOML"); + config.normalize_and_validate().expect("valid config"); + + let corp = &config.accounts["corp"]; + assert_eq!(corp.identities(), ["mark@example.com", "sales@example.com"]); + assert!( + corp.canonical_addresses() + .contains(&"login@example.com".to_string()), + "the login still counts as the account's own mail" + ); + assert_eq!( + config.accounts["listed"].identities(), + ["mark@example.com", "login@example.com"], + "listing the login as an alias makes it sendable" + ); + assert_eq!(config.accounts["gmail"].identities(), ["me@gmail.com"]); + assert!(config.accounts["icloud"].identities().is_empty()); + } + #[test] fn validated_programmatic_config_rejects_duplicate_names() { let error = Config::try_from_accounts(vec![ diff --git a/src/connection.rs b/src/connection.rs index f8b1184..9819eba 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -1,7 +1,7 @@ use hashbrown::HashMap; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{Duration, Instant}; +use std::time::{Duration, SystemTime}; use tokio::sync::{Mutex, OwnedSemaphorePermit, Semaphore}; use crate::AgentmailError; @@ -12,7 +12,30 @@ use crate::imap_client::{self, ImapSession}; /// evict ones the IMAP server has very likely already dropped. struct IdleSession { session: ImapSession, - idle_since: Instant, + /// Wall-clock, not `Instant` — see [`wall_elapsed`]. + idle_since: SystemTime, +} + +impl IdleSession { + /// Whether this session has been idle for less than `max_idle` as of `now`. + fn is_fresh(&self, max_idle: Duration, now: SystemTime) -> bool { + idle_is_fresh(wall_elapsed(self.idle_since, now), max_idle) + } +} + +/// Wall-clock time from `since` to `now`, or `None` when the clock reads +/// earlier than `since` — it was set back — which every caller treats as +/// expired. +/// +/// The pool's timers measure wall time on purpose. `Instant` is +/// `CLOCK_UPTIME_RAW` on macOS and stops while the machine sleeps, so after a +/// night asleep an idle session looked seconds old and was handed out, a +/// `[LIMIT]` cooldown resumed where it paused, and the mailbox catalog served a +/// layout loaded hours earlier. The server's clocks run through our sleep — it +/// drops idle connections and lifts its penalties on its own time — so these +/// must too. +pub(crate) fn wall_elapsed(since: SystemTime, now: SystemTime) -> Option { + now.duration_since(since).ok() } /// Connection pool managing IMAP sessions across accounts. @@ -163,9 +186,10 @@ const MAX_IDLE_UID_MODE: usize = 1; const MIN_KEEPALIVE: Duration = Duration::from_secs(30); /// Whether a session idle for `idle_for` is fresh enough to attempt reuse -/// against the pool's configured threshold. -fn idle_is_fresh(idle_for: Duration, max_idle: Duration) -> bool { - idle_for < max_idle +/// against the pool's configured threshold. An unknown idle time (the clock was +/// set back) is not fresh. +fn idle_is_fresh(idle_for: Option, max_idle: Duration) -> bool { + idle_for.is_some_and(|idle_for| idle_for < max_idle) } /// Ceiling for the escalating login cooldown. One hour outlasts observed @@ -179,15 +203,25 @@ const LOGIN_RATE_LIMIT_COOLDOWN_CAP: Duration = Duration::from_secs(3600); /// is the memory that decides whether the next LIMIT counts as consecutive. #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct LoginCooldown { - /// New LOGINs are refused until this instant. - until: Instant, + /// When this cooldown was armed. Wall-clock, like `until` — see + /// [`wall_elapsed`]. + armed_at: SystemTime, + /// New LOGINs are refused until this time. + until: SystemTime, /// Consecutive LIMIT strikes; 1 = first (or lapsed-episode) LIMIT. strikes: u32, } -/// Time left on a login cooldown that ends at `until`, `None` once it lifted. -fn cooldown_remaining(until: Instant, now: Instant) -> Option { - (until > now).then(|| until - now) +/// Time left on a login cooldown, `None` once it lifted — or once the clock has +/// been set back past the moment it was armed, which would otherwise stretch +/// the gate by however far the clock moved. +fn cooldown_remaining(cooldown: LoginCooldown, now: SystemTime) -> Option { + wall_elapsed(cooldown.armed_at, now)?; + cooldown + .until + .duration_since(now) + .ok() + .filter(|remaining| !remaining.is_zero()) } /// Cooldown applied at the given strike count: the base doubled per extra @@ -205,17 +239,21 @@ fn cooldown_after_strikes(base: Duration, strikes: u32) -> Duration { /// length — covering both a LIMIT while still armed and one shortly after /// expiry (the "server penalty outlasts our cooldown" case, where each /// expiry's single probing LOGIN gets re-LIMITed). At or past the window -/// boundary the episode has lapsed and the account starts over at strike 1. -fn next_cooldown(prev: Option, base: Duration, now: Instant) -> LoginCooldown { +/// boundary the episode has lapsed and the account starts over at strike 1, as +/// it does when the clock was set back past the previous arming. +fn next_cooldown(prev: Option, base: Duration, now: SystemTime) -> LoginCooldown { let strikes = match prev { Some(prev) - if now < prev.until + cooldown_after_strikes(base, prev.strikes).saturating_mul(2) => + if wall_elapsed(prev.armed_at, now).is_some() + && now + < prev.until + cooldown_after_strikes(base, prev.strikes).saturating_mul(2) => { prev.strikes.saturating_add(1) } _ => 1, }; LoginCooldown { + armed_at: now, until: now + cooldown_after_strikes(base, strikes), strikes, } @@ -414,7 +452,7 @@ impl ConnectionPool { let mut alive = Vec::with_capacity(idle_set.len()); for mut idle in idle_set { if imap_client::ping(&mut idle.session).await.is_ok() { - idle.idle_since = Instant::now(); + idle.idle_since = SystemTime::now(); alive.push(idle); stats.keepalive_pings.fetch_add(1, Ordering::Relaxed); } else { @@ -452,7 +490,7 @@ impl ConnectionPool { pub(crate) fn note_login_rate_limit(&self, account_name: &str) { let mut cooldowns = self.login_cooldowns.lock(); let prev = cooldowns.get(account_name).copied(); - let next = next_cooldown(prev, self.login_cooldown, Instant::now()); + let next = next_cooldown(prev, self.login_cooldown, SystemTime::now()); cooldowns.insert(account_name.to_string(), next); drop(cooldowns); tracing::warn!( @@ -484,7 +522,7 @@ impl ConnectionPool { /// strike-aware fast-fail message. `None` once the gate has lifted. pub(crate) fn login_cooldown_status(&self, account_name: &str) -> Option<(Duration, u32)> { let state = *self.login_cooldowns.lock().get(account_name)?; - cooldown_remaining(state.until, Instant::now()).map(|remaining| (remaining, state.strikes)) + cooldown_remaining(state, SystemTime::now()).map(|remaining| (remaining, state.strikes)) } /// Get the server capabilities for an account, fetching once and caching. @@ -554,10 +592,9 @@ impl ConnectionPool { pools.get_mut(account_name).and_then(|pool| pool.pop()) }; // lock released here — before any network I/O if let Some(idle) = maybe_idle { + let fresh = idle.is_fresh(self.max_idle, SystemTime::now()); let mut session = idle.session; - if idle_is_fresh(idle.idle_since.elapsed(), self.max_idle) - && imap_client::ping(&mut session).await.is_ok() - { + if fresh && imap_client::ping(&mut session).await.is_ok() { return Some((session, uid_mode)); } // too old or stale → `session` drops here (connection closes); @@ -767,10 +804,9 @@ impl ConnectionPool { pools.get_mut(account_name).and_then(|pool| pool.pop()) }; if let Some(idle) = maybe_idle { + let fresh = idle.is_fresh(self.max_idle, SystemTime::now()); let mut session = idle.session; - if idle_is_fresh(idle.idle_since.elapsed(), self.max_idle) - && imap_client::ping(&mut session).await.is_ok() - { + if fresh && imap_client::ping(&mut session).await.is_ok() { return Ok(Some(PooledSession { session: Some(session), account_name: account_name.to_string(), @@ -881,7 +917,7 @@ impl PooledSession { if pool.len() < cap { pool.push(IdleSession { session, - idle_since: Instant::now(), + idle_since: SystemTime::now(), }); } // else: drop the session (connection closes) @@ -989,29 +1025,55 @@ mod tests { #[test] fn idle_freshness_threshold() { assert!( - idle_is_fresh(Duration::from_secs(0), MAX_IDLE), + idle_is_fresh(Some(Duration::from_secs(0)), MAX_IDLE), "fresh session reusable" ); assert!( - idle_is_fresh(MAX_IDLE - Duration::from_secs(1), MAX_IDLE), + idle_is_fresh(Some(MAX_IDLE - Duration::from_secs(1)), MAX_IDLE), "just under the threshold is still reusable", ); assert!( - !idle_is_fresh(MAX_IDLE, MAX_IDLE), + !idle_is_fresh(Some(MAX_IDLE), MAX_IDLE), "exactly at the threshold is evicted" ); assert!( - !idle_is_fresh(MAX_IDLE + Duration::from_secs(30 * 60), MAX_IDLE), + !idle_is_fresh(Some(MAX_IDLE + Duration::from_secs(30 * 60)), MAX_IDLE), "long-idle (likely server-dropped) session is evicted", ); // A raised threshold (builder .max_idle) keeps older sessions eligible. assert!( idle_is_fresh( - MAX_IDLE + Duration::from_secs(60), + Some(MAX_IDLE + Duration::from_secs(60)), Duration::from_secs(20 * 60) ), "a raised max_idle admits sessions the default would evict", ); + assert!( + !idle_is_fresh(None, Duration::from_secs(20 * 60)), + "an idle time the clock cannot vouch for is not fresh", + ); + } + + /// A fixed wall-clock origin, so the timer tests are deterministic. + fn wall(secs: u64) -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000 + secs) + } + + /// The bug the wall clock fixes: idle time is measured from the stamps, so + /// a night asleep (which `Instant` on macOS does not count) still ages the + /// session past `max_idle`. + #[test] + fn wall_elapsed_measures_forward_time_and_refuses_a_clock_set_back() { + assert_eq!( + wall_elapsed(wall(0), wall(8 * 3600)), + Some(Duration::from_secs(8 * 3600)) + ); + assert_eq!(wall_elapsed(wall(60), wall(60)), Some(Duration::ZERO)); + assert_eq!(wall_elapsed(wall(60), wall(0)), None); + assert!( + !idle_is_fresh(wall_elapsed(wall(0), wall(8 * 3600)), MAX_IDLE), + "a session idled overnight is evicted, not pinged and reused" + ); } /// The keepalive task spawns once on first pool use when configured, and @@ -1044,27 +1106,54 @@ mod tests { #[test] fn cooldown_remaining_counts_down_and_lifts() { - let now = Instant::now(); + let now = wall(0); let until = now + Duration::from_secs(120); + let cooldown = LoginCooldown { + armed_at: now, + until, + strikes: 1, + }; assert_eq!( - cooldown_remaining(until, now), + cooldown_remaining(cooldown, now), Some(Duration::from_secs(120)) ); assert_eq!( - cooldown_remaining(until, now + Duration::from_secs(60)), + cooldown_remaining(cooldown, now + Duration::from_secs(60)), Some(Duration::from_secs(60)) ); assert_eq!( - cooldown_remaining(until, until), + cooldown_remaining(cooldown, until), None, "the gate lifts exactly at the deadline" ); assert_eq!( - cooldown_remaining(until, until + Duration::from_secs(1)), + cooldown_remaining(cooldown, until + Duration::from_secs(1)), None ); } + /// A cooldown armed before a sleep keeps counting through it, and a clock + /// set back past the arming lifts the gate rather than stretching it. + #[test] + fn cooldown_runs_on_the_wall_clock_and_a_clock_set_back_lifts_it() { + let cooldown = next_cooldown(None, Duration::from_secs(300), wall(1000)); + assert_eq!( + cooldown_remaining(cooldown, wall(1000 + 3600)), + None, + "an hour asleep outlasts a five-minute cooldown" + ); + assert_eq!( + cooldown_remaining(cooldown, wall(999)), + None, + "a clock set back past the arming cannot extend the gate" + ); + assert_eq!( + next_cooldown(Some(cooldown), Duration::from_secs(300), wall(999)).strikes, + 1, + "nor count as a consecutive LIMIT" + ); + } + /// The login-rate-limit gate: noting a LIMIT arms a cooldown for that /// account only, and it reports time remaining until it lifts. #[test] @@ -1126,7 +1215,7 @@ mod tests { #[test] fn next_cooldown_first_limit_is_strike_one() { let base = Duration::from_secs(300); - let now = Instant::now(); + let now = wall(0); let state = next_cooldown(None, base, now); assert_eq!(state.strikes, 1); assert_eq!(state.until, now + base); @@ -1135,7 +1224,7 @@ mod tests { #[test] fn next_cooldown_relimit_within_window_escalates() { let base = Duration::from_secs(300); - let t0 = Instant::now(); + let t0 = wall(0); let first = next_cooldown(None, base, t0); // Re-LIMIT while still armed → strike 2. let while_armed = next_cooldown(Some(first), base, t0 + Duration::from_secs(100)); @@ -1151,8 +1240,9 @@ mod tests { #[test] fn next_cooldown_relimit_at_or_after_window_starts_over() { let base = Duration::from_secs(300); - let t0 = Instant::now(); + let t0 = wall(0); let third = LoginCooldown { + armed_at: t0, until: t0 + Duration::from_secs(1200), strikes: 3, }; @@ -1169,12 +1259,12 @@ mod tests { #[test] fn next_cooldown_walks_the_escalation_ladder() { let base = Duration::from_secs(300); - let mut now = Instant::now(); + let mut now = wall(0); let mut state: Option = None; let mut applied = Vec::new(); for _ in 0..6 { let next = next_cooldown(state, base, now); - applied.push((next.until - now).as_secs()); + applied.push(next.until.duration_since(now).unwrap().as_secs()); // The next probe fires just after this cooldown expires — the // exact leak pattern observed live. now = next.until + Duration::from_secs(1); @@ -1261,6 +1351,7 @@ mod tests { username: "user@example.com".to_string(), email: None, aliases: Vec::new(), + display_name: None, password: Some(crate::secret::Secret::new_raw("unused")), tls: true, max_connections: None, diff --git a/src/draft.rs b/src/draft.rs index b49f80a..fa72b25 100644 --- a/src/draft.rs +++ b/src/draft.rs @@ -1,6 +1,10 @@ use lettre::message::header::ContentType; use lettre::message::{Attachment, Mailbox, Message, MultiPart, SinglePart}; +use crate::AgentmailError; +use crate::config::{AccountConfig, canonicalize_email, display_name_problem}; +use crate::types::MessageInfo; + #[derive(Clone, Copy)] pub(crate) struct DraftHeaderOptions<'a> { pub(crate) reply_to: &'a [String], @@ -40,7 +44,8 @@ pub fn extract_message_id(rfc822: &[u8]) -> Option { } /// Parse a string into a lettre Mailbox. -/// Accepts bare emails ("user@example.com") and full addresses ("Name "). +/// Accepts bare emails ("user@example.com") and full addresses ("Name "), +/// including a name left unquoted around a special ("Blake, Mark "). fn parse_mailbox(addr: &str) -> crate::Result { // Try direct parse first if let Ok(mbox) = addr.parse::() { @@ -48,11 +53,176 @@ fn parse_mailbox(addr: &str) -> crate::Result { } // If direct parse fails, try wrapping bare email in angle brackets let wrapped = format!("<{}>", addr.trim()); - wrapped.parse::().map_err(|e| { - crate::AgentmailError::Other(format!("Invalid email address '{}': {}", addr, e)) + let error = match wrapped.parse::() { + Ok(mbox) => return Ok(mbox), + Err(error) => error, + }; + parse_unquoted_name_addr(addr).ok_or_else(|| { + AgentmailError::Other(format!("Invalid email address '{}': {}", addr, error)) }) } +/// `Blake, Mark `: a display name holding an RFC 5322 special, left +/// unquoted — how people, and agents, write a "Last, First" name. Strictly ONE +/// mailbox: a single trailing `` and a name with no `<`, `>` or `@`, so +/// `Bob , Alice ` is still refused instead of becoming one mailbox +/// named `Bob , Alice` that silently drops Bob. +fn parse_unquoted_name_addr(value: &str) -> Option { + let inner = value.trim().strip_suffix('>')?; + let open = inner.rfind('<')?; + let name = inner[..open].trim(); + if name.is_empty() || name.contains(['<', '>', '@']) || name.chars().any(char::is_control) { + return None; + } + let email = inner[open + 1..].trim().parse::().ok()?; + Some(Mailbox::new(Some(name.to_string()), email)) +} + +/// The canonical address in `Name ` or a bare `addr`, for comparing a +/// header's mailbox with the account's own addresses. +pub(crate) fn canonical_recipient_address(value: &str) -> Option { + let value = value.trim(); + let candidate = value + .rfind('<') + .and_then(|start| value.get(start + 1..)) + .and_then(|tail| tail.strip_suffix('>')) + .unwrap_or(value) + .trim(); + canonicalize_email(candidate) +} + +/// A mailbox as `Name ` — the same quoting [`crate::parser`] gives a +/// header it read — for reporting a draft's sender. Never lettre's `Display`, +/// which fails on a CR or LF in the name. +pub(crate) fn format_mailbox(mailbox: &Mailbox) -> String { + crate::parser::format_name_addr( + mailbox.name.as_deref().unwrap_or(""), + mailbox.email.as_ref(), + ) +} + +/// The `From` of a stored message, for keeping a draft's sender across +/// `update_draft`. `None` when it is absent or not a usable address. Control +/// characters in the decoded name are dropped: an RFC 2047 word can decode to +/// anything, and a name is going back into a header. +pub(crate) fn extract_from_mailbox(rfc822: &[u8]) -> Option { + let parsed = mail_parser::MessageParser::default().parse(rfc822)?; + let from = parsed.from()?.first()?; + let email = from + .address + .as_deref()? + .trim() + .parse::() + .ok()?; + let name = from + .name + .as_deref() + .map(|name| name.chars().filter(|c| !c.is_control()).collect::()) + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()); + Some(Mailbox::new(name, email)) +} + +/// Decide a draft's `From`. Every address this can return is one of the +/// account's [`AccountConfig::identities`], and the name defaults to its +/// `display_name`. In order: +/// +/// 1. An `explicit` sender — `Name ` or `addr` — must be one of the +/// identities, or the draft is refused with the list of them. Its name, +/// when it has one, replaces the display name. +/// 2. A reply (`reply_source`) is From the identity the original involved: its +/// `From` when that is one of ours (a follow-up to our own mail), otherwise +/// the first of our identities among its `To`, then its `Cc` — the address +/// the sender actually wrote to. +/// 3. A replaced draft (`existing`) keeps its current `From` when that is one +/// of ours — name included, the display name filling a blank one — so an +/// alias picked in another mail client survives an edit here. +/// 4. Otherwise the primary identity. +/// +/// An account with no identity at all is refused with what to configure, +/// rather than composing a `From` out of a login name. +pub(crate) fn resolve_from( + account: &str, + config: &AccountConfig, + explicit: Option<&str>, + reply_source: Option<&MessageInfo>, + existing: Option<&Mailbox>, +) -> crate::Result { + let identities = config.identities(); + let Some(primary) = identities.first() else { + return Err(AgentmailError::Config(format!( + "account '{account}' has no email address to send from — set `email` in its \ + configuration, or add one in Mail Accounts settings" + ))); + }; + // `Config::from_accounts` normalizes without validating, so an embedder's + // name is checked here too: refused loudly rather than dropped. + let display_name = config.display_name.as_deref(); + if let Some(problem) = display_name.and_then(display_name_problem) { + return Err(AgentmailError::Config(format!( + "account '{account}': display_name {problem}" + ))); + } + let ours = |address: &str| canonicalize_email(address).filter(|a| identities.contains(a)); + + if let Some(explicit) = explicit.map(str::trim).filter(|value| !value.is_empty()) { + let requested = parse_mailbox(explicit)?; + let Some(identity) = ours(requested.email.as_ref()) else { + return Err(AgentmailError::Other(format!( + "`from` '{explicit}' is not an address of account '{account}' ({}); use one \ + of those, or add it to the account as an alias first", + identities.join(", ") + ))); + }; + let name = non_blank(requested.name.as_deref()); + if let Some(problem) = name.and_then(display_name_problem) { + return Err(AgentmailError::Other(format!( + "`from` '{explicit}': the name {problem}" + ))); + } + return sender(&identity, name.or(display_name)); + } + + if let Some(source) = reply_source { + let addressed = canonical_recipient_address(&source.sender) + .filter(|address| identities.contains(address)) + .or_else(|| { + source.to.iter().chain(&source.cc).find_map(|recipient| { + canonical_recipient_address(recipient) + .filter(|address| identities.contains(address)) + }) + }); + if let Some(identity) = addressed { + return sender(&identity, display_name); + } + } + + if let Some(existing) = existing + && ours(existing.email.as_ref()).is_some() + { + let name = non_blank(existing.name.as_deref()) + .filter(|name| display_name_problem(name).is_none()) + .or(display_name); + return Ok(Mailbox::new( + name.map(str::to_string), + existing.email.clone(), + )); + } + + sender(primary, display_name) +} + +fn non_blank(name: Option<&str>) -> Option<&str> { + name.map(str::trim).filter(|name| !name.is_empty()) +} + +fn sender(address: &str, name: Option<&str>) -> crate::Result { + let email = address.parse::().map_err(|error| { + AgentmailError::Other(format!("Invalid email address '{address}': {error}")) + })?; + Ok(Mailbox::new(name.map(str::to_string), email)) +} + /// Build an RFC822 message suitable for IMAP APPEND with \Draft flag. /// When attachments are provided, produces a multipart/mixed message. pub fn compose_draft( @@ -64,13 +234,14 @@ pub fn compose_draft( from: Option<&str>, attachments: &[crate::types::DraftAttachment], ) -> crate::Result> { + let from = from.map(parse_mailbox).transpose()?; compose_draft_with_headers( subject, body, to, cc, bcc, - from, + from.as_ref(), attachments, DraftHeaderOptions { reply_to: &[], @@ -83,6 +254,7 @@ pub fn compose_draft( } /// Build a Mail.app-compatible RFC822 draft with optional threading headers. +/// `from` is already resolved — see [`resolve_from`]. #[allow(clippy::too_many_arguments)] pub(crate) fn compose_draft_with_headers( subject: &str, @@ -90,7 +262,7 @@ pub(crate) fn compose_draft_with_headers( to: &[String], cc: &[String], bcc: &[String], - from: Option<&str>, + from: Option<&Mailbox>, attachments: &[crate::types::DraftAttachment], headers: DraftHeaderOptions<'_>, ) -> crate::Result> { @@ -105,8 +277,8 @@ pub(crate) fn compose_draft_with_headers( .message_id(None) .keep_bcc(); - if let Some(from_addr) = from { - builder = builder.from(parse_mailbox(from_addr)?); + if let Some(from) = from { + builder = builder.from(from.clone()); } for addr in to { @@ -250,6 +422,10 @@ mod tests { .expect("failed to parse generated RFC822") } + fn me() -> Mailbox { + "me@example.com".parse().expect("valid mailbox") + } + /// ASCII only, so no quoted-printable rewrites the bytes we assert on. const MD_BODY: &str = "Hello,\n\n**bold** and a [link](https://example.org/).\n\n- one\n- two\n"; @@ -261,7 +437,7 @@ mod tests { &["to@example.com".to_string()], &[], &[], - Some("me@example.com"), + Some(&me()), attachments, DraftHeaderOptions { reply_to: &[], @@ -590,7 +766,7 @@ mod tests { &["to@example.com".to_string()], &["cc@example.com".to_string()], &["blind@example.com".to_string()], - Some("me@example.com"), + Some(&me()), &[], DraftHeaderOptions { reply_to: &["answers@example.com".to_string()], @@ -617,6 +793,246 @@ mod tests { assert!(parsed.bcc().is_some()); } + fn account(display_name: Option<&str>) -> AccountConfig { + let mut config = AccountConfig::new("imap.example.com", "login-name") + .with_email("Mark@Example.com") + .with_aliases(["sales@example.com", "Mark@Example.org"]); + if let Some(name) = display_name { + config = config.with_display_name(name); + } + crate::Config::from_accounts(vec![("work".to_string(), config)]).accounts["work"].clone() + } + + fn message(headers: &str) -> MessageInfo { + let raw = format!("{headers}\r\nSubject: s\r\n\r\nbody"); + crate::parser::parse_rfc822( + raw.as_bytes(), + 1, + Vec::new(), + None, + "INBOX", + "work", + false, + false, + ) + .expect("parse source") + } + + fn resolved( + config: &AccountConfig, + explicit: Option<&str>, + reply_source: Option<&MessageInfo>, + existing: Option<&Mailbox>, + ) -> String { + format_mailbox( + &resolve_from("work", config, explicit, reply_source, existing).expect("resolves"), + ) + } + + #[test] + fn the_default_sender_is_the_display_name_on_the_primary_address() { + assert_eq!( + resolved(&account(Some("Mark Blake")), None, None, None), + "Mark Blake " + ); + assert_eq!( + resolved(&account(None), None, None, None), + "mark@example.com" + ); + } + + #[test] + fn an_explicit_sender_must_be_one_of_the_accounts_identities() { + let config = account(Some("Mark Blake")); + assert_eq!( + resolved(&config, Some("SALES@example.com"), None, None), + "Mark Blake ", + "a bare address takes the display name" + ); + assert_eq!( + resolved(&config, Some("Sales Team "), None, None), + "Sales Team ", + "a name given with the address wins" + ); + assert_eq!( + resolved(&config, Some(" "), None, None), + "Mark Blake ", + "a blank sender is no sender" + ); + + let error = resolve_from( + "work", + &config, + Some("Mark "), + None, + None, + ) + .expect_err("a foreign address is refused") + .to_string(); + assert!( + error.contains("mark@example.com, sales@example.com, mark@example.org") + && error.contains("alias"), + "the refusal lists the usable addresses and the fix: {error}" + ); + let error = resolve_from("work", &config, Some("login-name"), None, None) + .expect_err("a login is not an address") + .to_string(); + assert!(error.contains("Invalid email address"), "{error}"); + } + + #[test] + fn a_reply_is_from_the_identity_the_original_was_sent_to() { + let config = account(Some("Mark Blake")); + let via_to = + message("From: client@example.net\r\nTo: Sales , other@example.net"); + assert_eq!( + resolved(&config, None, Some(&via_to), None), + "Mark Blake " + ); + let via_cc = + message("From: client@example.net\r\nTo: other@example.net\r\nCc: mark@example.org"); + assert_eq!( + resolved(&config, None, Some(&via_cc), None), + "Mark Blake " + ); + let follow_up = + message("From: \"Blake, Mark\" \r\nTo: mark@example.com"); + assert_eq!( + resolved(&config, None, Some(&follow_up), None), + "Mark Blake ", + "a follow-up to our own mail stays on the address it was sent from" + ); + let bcc_only = message("From: client@example.net\r\nTo: list@example.net"); + assert_eq!( + resolved(&config, None, Some(&bcc_only), None), + "Mark Blake ", + "no identity in sight falls back to the primary" + ); + assert_eq!( + resolved(&config, Some("mark@example.com"), Some(&via_to), None), + "Mark Blake ", + "an explicit sender still wins" + ); + } + + #[test] + fn an_updated_draft_keeps_a_sender_of_ours_and_replaces_a_foreign_one() { + let config = account(Some("Mark Blake")); + let chosen: Mailbox = "\"Mark B.\" ".parse().expect("mailbox"); + assert_eq!( + resolved(&config, None, None, Some(&chosen)), + "\"Mark B.\" ", + "an alias picked in another client survives, name and spelling included" + ); + let bare: Mailbox = "sales@example.com".parse().expect("mailbox"); + assert_eq!( + resolved(&config, None, None, Some(&bare)), + "Mark Blake ", + "a bare sender of ours gains the display name" + ); + let foreign: Mailbox = "Someone ".parse().expect("mailbox"); + assert_eq!( + resolved(&config, None, None, Some(&foreign)), + "Mark Blake " + ); + } + + #[test] + fn an_account_with_no_address_is_refused_with_what_to_configure() { + let config = crate::Config::from_accounts(vec![( + "icloud".to_string(), + AccountConfig::new("imap.mail.me.com", "johnappleseed"), + )]) + .accounts["icloud"] + .clone(); + let error = resolve_from("icloud", &config, None, None, None) + .expect_err("no identity, no sender") + .to_string(); + assert!( + error.contains("account 'icloud' has no email address") + && error.contains("Mail Accounts settings"), + "{error}" + ); + } + + #[test] + fn a_sender_name_with_a_line_break_is_refused() { + let error = resolve_from( + "work", + &account(None), + Some("\"Mark\r\nBcc: x@evil.example\" "), + None, + None, + ) + .expect_err("a CR/LF name never reaches a header"); + assert!( + error.to_string().contains("Invalid email address"), + "{error}" + ); + + // `from_accounts` normalizes but does not validate, so a bad configured + // name must be caught at resolution. + let config = account(Some("Mark\r\nBcc: x@evil.example")); + let error = resolve_from("work", &config, None, None, None) + .expect_err("a configured CR/LF name is refused") + .to_string(); + assert!(error.contains("control characters"), "{error}"); + } + + /// The whole point: a name with a comma, quotes and an accent reaches the + /// header intact and reads back as ONE sender. + #[test] + fn a_display_name_with_specials_round_trips_through_the_header() { + let name = "Blake, Mark \"Shark\" José"; + let from = resolve_from("work", &account(Some(name)), None, None, None).expect("resolves"); + let raw = compose_draft_with_headers( + "s", + "b", + &["to@example.com".to_string()], + &[], + &[], + Some(&from), + &[], + DraftHeaderOptions { + reply_to: &[], + in_reply_to: None, + references: &[], + apple_uuid: uuid::Uuid::nil(), + body_format: BodyFormat::PlainOnly, + }, + ) + .expect("composes"); + + let parsed = parse(&raw); + let senders: Vec<_> = parsed.from().expect("a From header").iter().collect(); + assert_eq!(senders.len(), 1, "one sender, not split at the comma"); + assert_eq!(senders[0].name.as_deref(), Some(name)); + assert_eq!(senders[0].address.as_deref(), Some("mark@example.com")); + assert_eq!( + extract_from_mailbox(&raw).map(|mailbox| format_mailbox(&mailbox)), + Some("\"Blake, Mark \\\"Shark\\\" José\" ".to_string()), + "and reads back for update_draft unchanged" + ); + } + + #[test] + fn an_unquoted_last_first_name_parses_as_one_mailbox_and_two_mailboxes_do_not() { + let mailbox = parse_mailbox("Blake, Mark ").expect("tolerated"); + assert_eq!(mailbox.name.as_deref(), Some("Blake, Mark")); + assert_eq!(mailbox.email.to_string(), "mark@example.com"); + + for two in [ + "Bob , Alice ", + "bob@example.com, Alice ", + ] { + let error = parse_mailbox(two).expect_err("two mailboxes are not one"); + assert!( + error.to_string().contains("Invalid email address"), + "{error}" + ); + } + } + #[test] fn threading_headers_reject_injection_before_serialization() { let error = compose_draft_with_headers( @@ -625,7 +1041,7 @@ mod tests { &["to@example.com".to_string()], &[], &[], - Some("me@example.com"), + Some(&me()), &[], DraftHeaderOptions { reply_to: &[], diff --git a/src/header_cache.rs b/src/header_cache.rs index a0c1cc8..237215e 100644 --- a/src/header_cache.rs +++ b/src/header_cache.rs @@ -3187,6 +3187,7 @@ mod tests { username: "user@verizon.net".to_string(), email: None, aliases: Vec::new(), + display_name: None, password: None, tls: true, max_connections: None, @@ -3281,6 +3282,7 @@ mod tests { username: "user@example.com".to_string(), email: None, aliases: Vec::new(), + display_name: None, password: None, tls: true, max_connections: None, @@ -3335,6 +3337,7 @@ mod tests { username: "user@example.com".to_string(), email: None, aliases: Vec::new(), + display_name: None, password: None, tls: true, max_connections: None, diff --git a/src/imap_client.rs b/src/imap_client.rs index ecca209..c93c990 100644 --- a/src/imap_client.rs +++ b/src/imap_client.rs @@ -491,13 +491,13 @@ where Response::Done { tag: done, status, - information, - .. + outcome, } if done == &tag => { return match status { Status::Ok => Ok(enabled), other => Err(AgentmailError::Other(format!( - "ENABLE {extensions} rejected: {other:?} {information:?}" + "ENABLE {extensions} rejected: {other:?} {:?}", + outcome.information ))), }; } @@ -862,13 +862,70 @@ async fn send_client_id(session: &mut ImapSession) { /// Validate a session is still alive with NOOP. pub async fn ping(session: &mut ImapSession) -> Result<()> { - match tokio::time::timeout(PING_TIMEOUT, session.noop()).await { - Ok(Ok(_)) => Ok(()), - Ok(Err(e)) => Err(AgentmailError::Imap(e)), + match tokio::time::timeout(PING_TIMEOUT, noop_checked(session)).await { + Ok(result) => result, Err(_) => Err(AgentmailError::Other("IMAP ping timed out".into())), } } +/// `NOOP` that only a tagged `OK` can pass. +/// +/// async-imap's `noop()` cannot be a liveness probe: its `parse_noop` drains +/// with `take_while(filter)`, so a stream that simply ENDS returns `Ok`, and so +/// does a tagged `NO`. On macOS, security-framework maps a reset connection to a +/// zero-byte read — the same end-of-stream — so after sleep or a server `BYE` +/// every pooled session "passed" its ping and the next mutation failed on the +/// dead socket (an APPEND then reported an ambiguous outcome). Written on +/// `run_command` / `read_response` like [`enable`]: end-of-stream and an +/// untagged `BYE` are a lost connection, and unsolicited `EXISTS`/`EXPUNGE`/ +/// `FETCH` updates in between are skipped. +async fn noop_checked(session: &mut Session) -> Result<()> +where + T: AsyncRead + AsyncWrite + Unpin + fmt::Debug + Send, +{ + use async_imap::imap_proto::{Response, Status}; + + let tag = session.run_command("NOOP").await?; + loop { + let Some(response) = session.read_response().await? else { + return Err(AgentmailError::Imap( + async_imap::error::Error::ConnectionLost, + )); + }; + match response.parsed() { + Response::Data { + status: Status::Bye, + outcome, + } => { + debug!( + target: "agentmail", + information = ?outcome.information, + "server said BYE in reply to NOOP" + ); + return Err(AgentmailError::Imap( + async_imap::error::Error::ConnectionLost, + )); + } + Response::Done { + tag: done, + status, + outcome, + } if done == &tag => { + let detail = format!("NOOP: {:?}", outcome.information); + return match status { + Status::Ok => Ok(()), + Status::No => Err(AgentmailError::Imap(async_imap::error::Error::No(detail))), + Status::Bad => Err(AgentmailError::Imap(async_imap::error::Error::Bad(detail))), + other => Err(AgentmailError::Other(format!( + "NOOP completed with unexpected status {other:?}: {detail}" + ))), + }; + } + _ => {} + } + } +} + /// Query server capabilities via IMAP CAPABILITY command. pub async fn list_capabilities(session: &mut ImapSession) -> Result> { let mut result = capability_strings(session).await?; @@ -1537,6 +1594,24 @@ pub async fn fetch_sender_dates_for_uids( Ok(results) } +/// FROM + DATE rows for the `newest` most recent messages of `mailbox`, by +/// UID — which ascends in arrival order, so the highest UIDs are the newest +/// mail. EXAMINE, never SELECT, and `BODY.PEEK`: nothing is marked read. +pub(crate) async fn recent_sender_rows( + session: &mut ImapSession, + mailbox: &str, + newest: usize, +) -> Result> { + let opened = examine(session, mailbox).await?; + require_uid_validity(mailbox, opened.uid_validity)?; + if opened.exists == 0 || newest == 0 { + return Ok(Vec::new()); + } + let uids = search_all_uids_checked(session, opened.exists, opened.uid_next).await?; + let tail = &uids[uids.len().saturating_sub(newest)..]; + fetch_sender_dates_for_uids(session, tail, None, None).await +} + /// Fetch only FROM and DATE headers for all messages in a mailbox. /// Selects, searches all UIDs, and fetches them. Uses BODY.PEEK. pub async fn fetch_sender_dates( @@ -2374,8 +2449,10 @@ pub(crate) struct CopyUidMapping { pub(crate) pairs: Vec<(u32, u32)>, } -fn expand_uid_members(members: &[async_imap::imap_proto::UidSetMember]) -> Result> { - use async_imap::imap_proto::UidSetMember; +fn expand_uid_members( + members: &[async_imap::imap_proto::rfc4315::UidSetMember], +) -> Result> { + use async_imap::imap_proto::rfc4315::UidSetMember; let mut expanded = Vec::new(); for member in members { @@ -2409,8 +2486,8 @@ fn expand_uid_members(members: &[async_imap::imap_proto::UidSetMember]) -> Resul fn copy_uid_mapping( uid_validity: u32, - source: &[async_imap::imap_proto::UidSetMember], - destination: &[async_imap::imap_proto::UidSetMember], + source: &[async_imap::imap_proto::rfc4315::UidSetMember], + destination: &[async_imap::imap_proto::rfc4315::UidSetMember], ) -> Result { let source = expand_uid_members(source)?; let destination = expand_uid_members(destination)?; @@ -2442,7 +2519,7 @@ async fn checked_mutation_command( where T: AsyncRead + AsyncWrite + Unpin + fmt::Debug + Send, { - use async_imap::imap_proto::{Response, ResponseCode, Status}; + use async_imap::imap_proto::{Outcome, Response, ResponseCode, Status}; imap_timeout(async { let request_id = session.run_command(command).await?; @@ -2455,16 +2532,18 @@ where match response.parsed() { Response::Data { status: Status::Ok, - code: Some(ResponseCode::CopyUid(validity, source, destination)), - .. + outcome: + Outcome { + code: Some(ResponseCode::CopyUid(validity, source, destination)), + .. + }, } => { copy_uid = Some(copy_uid_mapping(*validity, source, destination)?); } Response::Done { tag, status, - code, - information, + outcome: Outcome { code, information }, } if tag == &request_id => { if let Some(ResponseCode::CopyUid(validity, source, destination)) = code { copy_uid = Some(copy_uid_mapping(*validity, source, destination)?); @@ -3102,7 +3181,7 @@ pub async fn append_draft( fn append_uid_identity( uid_validity: u32, - members: &[async_imap::imap_proto::UidSetMember], + members: &[async_imap::imap_proto::rfc4315::UidSetMember], ) -> Result> { let uids = expand_uid_members(members)?; match uids.as_slice() { @@ -3138,7 +3217,7 @@ pub async fn replace_draft( expected_uid_validity: u32, rfc822_message: &[u8], ) -> Result> { - use async_imap::imap_proto::{Response, ResponseCode, Status}; + use async_imap::imap_proto::{Outcome, Response, ResponseCode, Status}; select_with_expected_uid_validity(session, drafts_mailbox, expected_uid_validity).await?; let mailbox = quote_imap_string(drafts_mailbox)?; @@ -3153,12 +3232,11 @@ pub async fn replace_draft( .await? .ok_or(AgentmailError::NotConnected)?; match response.parsed() { - Response::Continue { .. } => break, + Response::Continue(_) => break, Response::Done { tag: done, status, - information, - .. + outcome: Outcome { information, .. }, } if done == &tag => { return Err(match status { Status::No => AgentmailError::Imap(async_imap::error::Error::No(format!( @@ -3188,14 +3266,16 @@ pub async fn replace_draft( match response.parsed() { Response::Data { status: Status::Ok, - code: Some(ResponseCode::AppendUid(uid_validity, members)), - .. + outcome: + Outcome { + code: Some(ResponseCode::AppendUid(uid_validity, members)), + .. + }, } => identity = append_uid_identity(*uid_validity, members)?, Response::Done { tag: done, status, - code, - information, + outcome: Outcome { code, information }, } if done == &tag => { if let Some(ResponseCode::AppendUid(uid_validity, members)) = code { identity = append_uid_identity(*uid_validity, members)?; @@ -4802,13 +4882,160 @@ mod tests { server.await.expect("scripted server should finish"); } + /// `list_identities` reads only the newest Sent mail: EXAMINE (never + /// SELECT), one SEARCH, then a `BODY.PEEK` fetch of the highest UIDs. + #[tokio::test] + async fn recent_sender_rows_fetch_only_the_newest_uids_without_marking_them_read() { + let (mut session, server) = test_support::scripted_session(|tag, command| { + if command.contains("EXAMINE") { + Some(test_support::examine_reply(tag, 9, 31, 4)) + } else if command.contains("UID SEARCH") { + Some(format!("* SEARCH 3 10 20 30\r\n{tag} OK SEARCH completed\r\n")) + } else if command.contains("UID FETCH") { + let fetch = |seq: u32, uid: u32, from: &str| { + let headers = format!( + "From: {from}\r\nDate: Tue, 01 Sep 2026 12:00:00 +0000\r\n\r\n" + ); + format!( + "* {seq} FETCH (UID {uid} BODY[HEADER.FIELDS (FROM DATE MESSAGE-ID)] {{{}}}\r\n{headers})\r\n", + headers.len() + ) + }; + Some(format!( + "{}{}{tag} OK FETCH completed\r\n", + fetch(3, 20, "\"Blake, Mark\" "), + fetch(4, 30, "sales@example.com"), + )) + } else { + None + } + }) + .await; + + let rows = recent_sender_rows(&mut session, "Sent", 2) + .await + .expect("rows for the newest two"); + + let summary: Vec<(u32, &str, &str)> = rows + .iter() + .map(|row| (row.uid, row.email.as_str(), row.display_name.as_str())) + .collect(); + assert_eq!( + summary, + [ + (20, "mark@example.com", "Blake, Mark"), + (30, "sales@example.com", "") + ] + ); + drop(session); + let commands = server.commands().await; + let fetch = commands + .iter() + .find(|command| command.contains("UID FETCH")) + .expect("a fetch was sent"); + assert!(fetch.contains("UID FETCH 20,30 "), "{fetch}"); + assert!(fetch.contains("BODY.PEEK["), "{fetch}"); + assert!( + !commands.iter().any(|command| command.contains(" SELECT ")), + "{commands:?}" + ); + } + + /// The bug [`noop_checked`] exists for, pinned against the library: a NOOP + /// whose connection ends before any reply is reported as success. If this + /// starts failing, async-imap fixed `parse_noop` and the hand-written NOOP + /// could go back to `session.noop()`. + #[tokio::test] + async fn async_imaps_own_noop_reports_a_closed_stream_as_alive() { + let (mut session, server) = test_support::scripted_session(|_, _| None).await; + + assert!( + session.noop().await.is_ok(), + "async-imap 0.12 still treats end-of-stream as a completed NOOP" + ); + drop(session); + server.commands().await; + } + + #[tokio::test] + async fn ping_fails_when_the_connection_ends_before_the_tagged_reply() { + let (mut session, server) = test_support::scripted_session(|_, _| None).await; + + let error = ping(&mut session) + .await + .expect_err("end-of-stream is a dead session, not a live one"); + assert!(error.is_connection_error(), "{error:?}"); + drop(session); + server.commands().await; + } + + #[tokio::test] + async fn ping_fails_on_an_untagged_bye_without_waiting_for_the_tag() { + let (mut session, server) = test_support::scripted_session(|_, command| { + command + .contains(" NOOP") + .then(|| "* BYE Autologout; idle for too long\r\n".to_string()) + }) + .await; + + let error = ping(&mut session) + .await + .expect_err("a server that said BYE is closing the session"); + assert!(error.is_connection_error(), "{error:?}"); + drop(session); + server.commands().await; + } + + #[tokio::test] + async fn ping_passes_only_on_its_own_tagged_ok_skipping_unsolicited_updates() { + let (mut session, server) = test_support::scripted_session(|tag, command| { + command.contains(" NOOP").then(|| { + format!( + "* 4 EXISTS\r\n* 2 EXPUNGE\r\n* 1 FETCH (FLAGS (\\Seen))\r\n\ + {tag} OK NOOP completed\r\n" + ) + }) + }) + .await; + + ping(&mut session) + .await + .expect("a tagged OK after unsolicited updates is a live session"); + drop(session); + let commands = server.commands().await; + assert!( + commands.iter().any(|c| c.ends_with("NOOP\r\n")), + "{commands:?}" + ); + } + + #[tokio::test] + async fn ping_fails_on_a_tagged_no() { + let (mut session, server) = test_support::scripted_session(|tag, command| { + command + .contains(" NOOP") + .then(|| format!("{tag} NO [UNAVAILABLE] backend down\r\n")) + }) + .await; + + let error = ping(&mut session) + .await + .expect_err("a refused NOOP is not a healthy session"); + assert!( + matches!(error, AgentmailError::Imap(async_imap::error::Error::No(_))), + "{error:?}" + ); + drop(session); + server.commands().await; + } + /// The exact shape AOL returns for a UID-only walk page: /// `* UIDFETCH (UID )` with the UID present inside too. #[test] fn uidonly_uidfetch_line_parses_via_the_patched_imap_proto() { use async_imap::imap_proto::{self, Response}; let line = b"* 434894 UIDFETCH (UID 434894)\r\n"; - let (rest, response) = imap_proto::parser::parse_response(line) + let (rest, response) = imap_proto::Response::parse(line) .expect("patched imap-proto must parse a UID-only UIDFETCH line"); assert!(rest.is_empty(), "the whole line is consumed: {rest:?}"); match response { @@ -4838,7 +5065,7 @@ mod tests { "* 434894 UIDFETCH (UID 434894 BODY[HEADER.FIELDS (List-Id From)] {{{}}}\r\n{headers})\r\n", headers.len() ); - let (rest, response) = imap_proto::parser::parse_response(line.as_bytes()) + let (rest, response) = imap_proto::Response::parse(line.as_bytes()) .expect("patched imap-proto must parse a UIDFETCH with a body-section literal"); assert!(rest.is_empty(), "the whole line is consumed: {rest:?}"); match response { @@ -5256,7 +5483,7 @@ mod tests { #[test] fn copyuid_ranges_expand_in_protocol_order_and_reject_mismatch() { - use async_imap::imap_proto::UidSetMember; + use async_imap::imap_proto::rfc4315::UidSetMember; let mapping = copy_uid_mapping( 9, diff --git a/src/lib.rs b/src/lib.rs index 74a50eb..6d859d8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,7 +21,7 @@ mod mutation_journal; mod scan_plan; mod unsubscribe; -pub use config::{AccountConfig, AuthMethod, Config}; +pub use config::{AccountConfig, AuthMethod, Config, canonicalize_email, validate_display_name}; pub use connection::{ ConnectionPool, ConnectionStats, is_login_rate_limited_host, recommended_max_connections, }; @@ -454,6 +454,8 @@ impl Agentmail { host: cfg.host.clone(), username: cfg.username.clone(), is_default: default == Some(name.as_str()), + display_name: cfg.display_name.clone(), + addresses: cfg.identities(), }) .collect(); accounts.sort_by(|a, b| a.name.cmp(&b.name)); @@ -461,6 +463,55 @@ impl Agentmail { Ok(ListAccountsResponse { accounts }) } + /// The addresses an account sends as: every configured identity, and + /// every From address among its newest `sent_messages` Sent messages. + /// + /// IMAP has no identity list — Gmail's send-as and JMAP `Identity` need + /// API credentials an app-password account does not hold — so what the + /// account has actually sent is the evidence. Reading it never widens what + /// a draft may be `from`: an address seen only in Sent is reported with + /// `configured: false` and must be added as an alias first. Without a Sent + /// mailbox the configured addresses are still returned. + pub async fn list_identities( + &self, + account: &str, + sent_messages: usize, + ) -> Result { + if !(1..=MAX_IDENTITY_SCAN).contains(&sent_messages) { + return Err(AgentmailError::Other(format!( + "sentMessages must be between 1 and {MAX_IDENTITY_SCAN}" + ))); + } + let config = self + .pool + .account_config(account) + .ok_or_else(|| AgentmailError::AccountNotFound(account.to_string()))?; + let configured = config.identities(); + let display_name = config.display_name.clone(); + + let layout = self.cached_mailbox_layout(account).await?; + let sent_mailbox = mailbox_catalog::resolve_sent(&layout); + let rows = match &sent_mailbox { + None => Vec::new(), + Some(mailbox) => { + let mailbox = mailbox.clone(); + self.pool + .with_session_retry(account, async move |session| { + imap_client::recent_sender_rows(session, &mailbox, sent_messages).await + }) + .await? + } + }; + + Ok(ListIdentitiesResponse { + account: account.to_string(), + display_name, + sent_mailbox, + scanned_messages: rows.len(), + identities: aggregate_identities(&configured, &rows), + }) + } + /// Check IMAP connectivity for an account. /// /// Probe contract: a parameter error (unknown account) is an `Err`, while @@ -3786,6 +3837,7 @@ impl Agentmail { ) -> Result { self.create_draft_with_headers( account, + None, subject, body, to, @@ -3802,10 +3854,16 @@ impl Agentmail { } /// Create a draft with Reply-To and RFC threading headers. + /// + /// `from` picks the sender, as `Name ` or `addr`. The address must be + /// one of the account's [`config::AccountConfig::identities`] or the draft + /// is refused; the name defaults to the configured `display_name`. `None` + /// sends as the primary identity. #[allow(clippy::too_many_arguments)] pub async fn create_draft_with_headers( &self, account: &str, + from: Option<&str>, subject: &str, body: &str, to: &[String], @@ -3819,23 +3877,57 @@ impl Agentmail { body_format: draft::BodyFormat, ) -> Result { validate_draft_payload(body, to, cc, bcc, reply_to, attachments)?; - - let _mutation_guard = self.lock_account_mutation(account).await; - let account_config = self .pool .account_config(account) .ok_or_else(|| AgentmailError::AccountNotFound(account.to_string()))?; - let from = account_config - .canonical_email() - .unwrap_or_else(|| account_config.username.clone()); + let from = draft::resolve_from(account, account_config, from, None, None)?; + self.save_new_draft( + account, + &from, + subject, + body, + to, + cc, + bcc, + reply_to, + in_reply_to, + references, + attachments, + warning, + body_format, + ) + .await + } + + /// Compose a new draft From an already-resolved sender and APPEND it to + /// the account's Drafts mailbox. + #[allow(clippy::too_many_arguments)] + async fn save_new_draft( + &self, + account: &str, + from: &lettre::message::Mailbox, + subject: &str, + body: &str, + to: &[String], + cc: &[String], + bcc: &[String], + reply_to: &[String], + in_reply_to: Option<&str>, + references: &[String], + attachments: &[crate::types::DraftAttachment], + warning: Option, + body_format: draft::BodyFormat, + ) -> Result { + let _mutation_guard = self.lock_account_mutation(account).await; + let rfc822 = draft::compose_draft_with_headers( subject, body, to, cc, bcc, - Some(&from), + Some(from), attachments, draft::DraftHeaderOptions { reply_to, @@ -3880,6 +3972,7 @@ impl Agentmail { created: true, account: account.to_string(), drafts_mailbox: drafts_name, + from: draft::format_mailbox(from), subject: subject.to_string(), recipients: DraftRecipients { to: to.to_vec(), @@ -3898,10 +3991,16 @@ impl Agentmail { } /// Create a reply or reply-all draft from one live message identity. + /// + /// The draft is From the account identity the original involved — its own + /// `From` for a follow-up to this account's mail, else the first identity + /// it was addressed to — unless `from` names another of the account's + /// addresses (see [`Self::create_draft_with_headers`]). #[allow(clippy::too_many_arguments)] pub async fn create_reply_draft( &self, account: &str, + from: Option<&str>, mailbox: &str, uid: u32, expected_uid_validity: u32, @@ -3913,6 +4012,10 @@ impl Agentmail { attachments: &[DraftAttachment], body_format: draft::BodyFormat, ) -> Result { + let account_config = self + .pool + .account_config(account) + .ok_or_else(|| AgentmailError::AccountNotFound(account.to_string()))?; let response = self .get_messages_by_uid( mailbox, @@ -3928,70 +4031,31 @@ impl Agentmail { .into_iter() .next() .ok_or(AgentmailError::MessageNotFound(uid))?; - let own = self.own_addresses(account); - let reply_target = if source.reply_to.trim().is_empty() { - source.sender.clone() - } else { - source.reply_to.clone() - }; - let mut seen = hashbrown::HashSet::new(); - let mut to = Vec::new(); - push_reply_recipient(&mut to, &mut seen, &own, &reply_target); - let mut cc = Vec::new(); - if mode == ReplyMode::ReplyAll { - for recipient in &source.to { - push_reply_recipient(&mut to, &mut seen, &own, recipient); - } - for recipient in &source.cc { - push_reply_recipient(&mut cc, &mut seen, &own, recipient); - } - } - if to.is_empty() && mode == ReplyMode::Reply { - for recipient in source.to.iter().chain(&source.cc) { - push_reply_recipient(&mut to, &mut seen, &own, recipient); - if !to.is_empty() { - break; - } - } - } - if to.is_empty() && cc.is_empty() && bcc.is_empty() { + let reply = derive_reply(&source, &self.own_addresses(account), mode); + if reply.to.is_empty() && reply.cc.is_empty() && bcc.is_empty() { return Err(AgentmailError::Other( "the source message has no reply recipient outside this account".to_string(), )); } - + validate_draft_payload(body, &reply.to, &reply.cc, bcc, reply_to, attachments)?; + let from = draft::resolve_from(account, account_config, from, Some(&source), None)?; let subject = subject .map(str::trim) .filter(|subject| !subject.is_empty()) - .map_or_else(|| reply_subject(&source.subject), str::to_string); - let mut references = source.references; - let (in_reply_to, warning) = match source.message_id { - Some(message_id) => { - if !references.iter().any(|reference| reference == &message_id) { - references.push(message_id.clone()); - } - (Some(message_id), None) - } - None => ( - None, - Some( - "source message has no Message-ID; recipients and subject were prepared, but RFC thread headers could not be applied" - .to_string(), - ), - ), - }; - self.create_draft_with_headers( + .map_or(reply.subject, str::to_string); + self.save_new_draft( account, + &from, &subject, body, - &to, - &cc, + &reply.to, + &reply.cc, bcc, reply_to, - in_reply_to.as_deref(), - &references, + reply.in_reply_to.as_deref(), + &reply.references, attachments, - warning, + reply.warning, body_format, ) .await @@ -4014,10 +4078,15 @@ impl Agentmail { /// risk; it exported it. Agents simply ran `create_draft` + `delete_messages` /// by hand, which is the same two commands with none of the guards below — /// no `\Draft` verification, no UIDVALIDITY fence, no policy-aware discard. + /// + /// `from` follows [`Self::create_draft_with_headers`]. Without it the draft + /// keeps its current `From` when that is one of the account's identities, + /// so a sender chosen in another mail client survives the edit. #[allow(clippy::too_many_arguments)] pub async fn update_draft( &self, account: &str, + from: Option<&str>, drafts_mailbox: &str, uid: u32, expected_uid_validity: u32, @@ -4038,9 +4107,10 @@ impl Agentmail { .pool .account_config(account) .ok_or_else(|| AgentmailError::AccountNotFound(account.to_string()))?; - let from = account_config - .canonical_email() - .unwrap_or_else(|| account_config.username.clone()); + let explicit_from = from.map(str::trim).filter(|from| !from.is_empty()); + // Refuse a sender that is not the account's before touching the server. + // Without one, the draft's current From is read further down. + let mut from = draft::resolve_from(account, account_config, explicit_from, None, None)?; let mut session = self.pool.acquire(account).await?; let caps = self.pool.server_caps(account, session.session()).await?; imap_client::examine_with_expected_uid_validity( @@ -4080,6 +4150,10 @@ impl Agentmail { .await?; let apple_uuid = draft::extract_apple_uuid(¤t_source).unwrap_or_else(uuid::Uuid::new_v4); + if explicit_from.is_none() { + let existing = draft::extract_from_mailbox(¤t_source); + from = draft::resolve_from(account, account_config, None, None, existing.as_ref())?; + } let replacement = draft::compose_draft_with_headers( subject, body, @@ -4149,6 +4223,7 @@ impl Agentmail { updated: true, account: account.to_string(), drafts_mailbox: drafts_mailbox.to_string(), + from: draft::format_mailbox(&from), previous_uid_validity: expected_uid_validity, previous_uid: uid, uid_validity: identity.map(|(uid_validity, _)| uid_validity), @@ -4776,67 +4851,7 @@ impl Agentmail { }); } - // Write files using async I/O - let output_dir = output_dir.to_path_buf(); - #[cfg(unix)] - let output_dir_existed = output_dir.exists(); - tokio::fs::create_dir_all(&output_dir).await.map_err(|e| { - AgentmailError::Other(format!( - "Failed to create directory '{}': {}", - output_dir.display(), - e - )) - })?; - #[cfg(unix)] - if !output_dir_existed { - tokio::fs::set_permissions( - &output_dir, - std::os::unix::fs::PermissionsExt::from_mode(0o700), - ) - .await - .map_err(|error| { - AgentmailError::Other(format!( - "Failed to make download directory '{}' private: {error}", - output_dir.display() - )) - })?; - } - - let mut downloaded = Vec::new(); - for (index, (name, content_type, bytes)) in attachments.iter().enumerate() { - let filename = format!("{}_{}_{}", uid, index, sanitize_filename(name)); - let path = output_dir.join(&filename); - let mut options = tokio::fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - options.mode(0o600); - } - let mut file = options.open(&path).await.map_err(|error| { - if error.kind() == std::io::ErrorKind::AlreadyExists { - AgentmailError::Other(format!( - "refusing to overwrite existing attachment '{}'", - path.display() - )) - } else { - AgentmailError::Other(format!("Failed to create '{}': {error}", path.display())) - } - })?; - file.write_all(bytes).await.map_err(|error| { - AgentmailError::Other(format!("Failed to write '{}': {error}", path.display())) - })?; - file.flush().await.map_err(|error| { - AgentmailError::Other(format!("Failed to flush '{}': {error}", path.display())) - })?; - - downloaded.push(DownloadedFile { - index, - path: filename.clone(), - filename, - content_type: content_type.clone(), - size: bytes.len(), - }); - } + let downloaded = write_attachment_files(output_dir, uid, &attachments).await?; Ok(DownloadAttachmentsResponse { mailbox: mailbox.to_string(), @@ -5571,15 +5586,87 @@ fn require_expected_message_count(expected: Option, actual: u32) -> Result< } } -fn canonical_recipient_address(value: &str) -> Option { - let value = value.trim(); - let candidate = value - .rfind('<') - .and_then(|start| value.get(start + 1..)) - .and_then(|tail| tail.strip_suffix('>')) - .unwrap_or(value) - .trim(); - crate::config::canonicalize_email(candidate) +/// What a reply takes from the message it answers: recipients, the `Re:` +/// subject and the RFC threading headers. +#[derive(Debug, Clone, PartialEq, Eq)] +struct ReplyDerivation { + to: Vec, + cc: Vec, + subject: String, + in_reply_to: Option, + references: Vec, + warning: Option, +} + +/// Derive a reply to `source`, leaving out the account's `own` addresses. +/// +/// The reply goes to EVERY `Reply-To` address — a message that asks for +/// answers at two addresses means both — and to `From` only when there is no +/// `Reply-To`. `ReplyAll` adds the original `To` and `Cc`. A plain reply whose +/// only target is this account (a follow-up to our own message) falls back to +/// the first outside recipient of the original. +fn derive_reply( + source: &MessageInfo, + own: &hashbrown::HashSet, + mode: ReplyMode, +) -> ReplyDerivation { + let mut seen = hashbrown::HashSet::new(); + let mut to = Vec::new(); + let reply_targets: Vec<&str> = source + .reply_to + .iter() + .map(|target| target.trim()) + .filter(|target| !target.is_empty()) + .collect(); + if reply_targets.is_empty() { + push_reply_recipient(&mut to, &mut seen, own, &source.sender); + } else { + for target in reply_targets { + push_reply_recipient(&mut to, &mut seen, own, target); + } + } + let mut cc = Vec::new(); + if mode == ReplyMode::ReplyAll { + for recipient in &source.to { + push_reply_recipient(&mut to, &mut seen, own, recipient); + } + for recipient in &source.cc { + push_reply_recipient(&mut cc, &mut seen, own, recipient); + } + } + if to.is_empty() && mode == ReplyMode::Reply { + for recipient in source.to.iter().chain(&source.cc) { + push_reply_recipient(&mut to, &mut seen, own, recipient); + if !to.is_empty() { + break; + } + } + } + + let mut references = source.references.clone(); + let (in_reply_to, warning) = match &source.message_id { + Some(message_id) => { + if !references.iter().any(|reference| reference == message_id) { + references.push(message_id.clone()); + } + (Some(message_id.clone()), None) + } + None => ( + None, + Some( + "source message has no Message-ID; recipients and subject were prepared, but RFC thread headers could not be applied" + .to_string(), + ), + ), + }; + ReplyDerivation { + to, + cc, + subject: reply_subject(&source.subject), + in_reply_to, + references, + warning, + } } fn push_reply_recipient( @@ -5592,8 +5679,8 @@ fn push_reply_recipient( if recipient.is_empty() { return; } - let identity = - canonical_recipient_address(recipient).unwrap_or_else(|| recipient.to_ascii_lowercase()); + let identity = draft::canonical_recipient_address(recipient) + .unwrap_or_else(|| recipient.to_ascii_lowercase()); if own.contains(&identity) || !seen.insert(identity) { return; } @@ -5614,6 +5701,111 @@ fn reply_subject(subject: &str) -> String { } } +/// Default number of newest Sent messages `list_identities` reads. +pub const DEFAULT_IDENTITY_SCAN: usize = 200; +/// Most Sent messages one `list_identities` call reads. +pub const MAX_IDENTITY_SCAN: usize = 1_000; +/// Most addresses one `list_identities` call reports. +const MAX_IDENTITY_ROWS: usize = 50; +/// Most display names reported per address. +const MAX_IDENTITY_NAMES: usize = 5; + +/// Fold Sent-mail From rows into one [`SenderIdentity`] per address. +/// +/// Configured addresses come first, in configuration order (primary, then +/// aliases), each reported even when no scanned message used it. Addresses +/// seen only in Sent follow, most used first, then most recently used. Display +/// names are counted per address and the most used kept first. +fn aggregate_identities( + configured: &[String], + sent: &[scan_cache::SenderRow], +) -> Vec { + /// How often, and how recently, something was used. + #[derive(Default)] + struct Usage { + count: usize, + newest: Option>, + } + + impl Usage { + fn record(&mut self, date: Option>) { + self.count += 1; + self.newest = self.newest.max(date); + } + } + + #[derive(Default)] + struct Tally { + messages: Usage, + names: hashbrown::HashMap, + } + + let mut tallies: hashbrown::HashMap<&str, Tally> = hashbrown::HashMap::new(); + for row in sent.iter().filter(|row| !row.email.is_empty()) { + let tally = tallies.entry(row.email.as_str()).or_default(); + tally.messages.record(row.date); + let name = row.display_name.trim(); + if !name.is_empty() { + tally + .names + .entry(name.to_string()) + .or_default() + .record(row.date); + } + } + + let identity = |address: &str, tally: Option<&Tally>, configured: bool, primary: bool| { + let mut names: Vec<(&String, &Usage)> = tally + .map(|tally| tally.names.iter().collect()) + .unwrap_or_default(); + names.sort_by(|(left_name, left), (right_name, right)| { + right + .count + .cmp(&left.count) + .then_with(|| right.newest.cmp(&left.newest)) + .then_with(|| left_name.cmp(right_name)) + }); + SenderIdentity { + address: address.to_string(), + configured, + primary, + display_names: names + .into_iter() + .take(MAX_IDENTITY_NAMES) + .map(|(name, _)| name.clone()) + .collect(), + messages: tally.map_or(0, |tally| tally.messages.count), + last_used: tally.and_then(|tally| tally.messages.newest), + } + }; + + let mut identities: Vec = configured + .iter() + .enumerate() + .map(|(index, address)| identity(address, tallies.get(address.as_str()), true, index == 0)) + .collect(); + let mut discovered: Vec<(&str, &Tally)> = tallies + .iter() + .filter(|(address, _)| !configured.iter().any(|configured| configured == *address)) + .map(|(address, tally)| (*address, tally)) + .collect(); + discovered.sort_by(|(left, left_tally), (right, right_tally)| { + right_tally + .messages + .count + .cmp(&left_tally.messages.count) + .then_with(|| right_tally.messages.newest.cmp(&left_tally.messages.newest)) + .then_with(|| left.cmp(right)) + }); + identities.extend( + discovered + .into_iter() + .map(|(address, tally)| identity(address, Some(tally), false, false)), + ); + identities.truncate(MAX_IDENTITY_ROWS); + identities +} + fn validate_draft_payload( body: &str, to: &[String], @@ -6273,6 +6465,85 @@ pub(crate) fn validate_plain_filename(filename: &str) -> Result<()> { Ok(()) } +/// Write one message's attachments into `output_dir` as `{uid}_{index}_{name}`, +/// creating the directory private (0700) when it is new. Each file is +/// create-new and 0600, so an existing file is refused, never overwritten. +async fn write_attachment_files( + output_dir: &std::path::Path, + uid: u32, + attachments: &[(String, String, Vec)], +) -> Result> { + #[cfg(unix)] + let output_dir_existed = output_dir.exists(); + tokio::fs::create_dir_all(output_dir).await.map_err(|e| { + AgentmailError::Other(format!( + "Failed to create directory '{}': {}", + output_dir.display(), + e + )) + })?; + #[cfg(unix)] + if !output_dir_existed { + tokio::fs::set_permissions( + output_dir, + std::os::unix::fs::PermissionsExt::from_mode(0o700), + ) + .await + .map_err(|error| { + AgentmailError::Other(format!( + "Failed to make download directory '{}' private: {error}", + output_dir.display() + )) + })?; + } + + let mut downloaded = Vec::new(); + for (index, (name, content_type, bytes)) in attachments.iter().enumerate() { + let filename = format!("{}_{}_{}", uid, index, sanitize_filename(name)); + let path = output_dir.join(&filename); + let mut options = tokio::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + options.mode(0o600); + } + let mut file = options.open(&path).await.map_err(|error| { + if error.kind() == std::io::ErrorKind::AlreadyExists { + AgentmailError::Other(format!( + "refusing to overwrite existing attachment '{}'", + path.display() + )) + } else { + AgentmailError::Other(format!("Failed to create '{}': {error}", path.display())) + } + })?; + file.write_all(bytes).await.map_err(|error| { + AgentmailError::Other(format!("Failed to write '{}': {error}", path.display())) + })?; + file.flush().await.map_err(|error| { + AgentmailError::Other(format!("Failed to flush '{}': {error}", path.display())) + })?; + // The absolute path, as `download_message_source` returns: a bare + // filename names nothing once the caller's working directory is not + // `output_dir`, which for the MCP server is always the case. + let path = tokio::fs::canonicalize(&path).await.map_err(|error| { + AgentmailError::Other(format!( + "saved attachment but could not resolve '{}': {error}", + path.display() + )) + })?; + + downloaded.push(DownloadedFile { + index, + path: path.display().to_string(), + filename, + content_type: content_type.clone(), + size: bytes.len(), + }); + } + Ok(downloaded) +} + /// Create, durably write, and close one private file. Any ordinary write error /// removes the incomplete file; `create_new` is the no-overwrite boundary. pub(crate) async fn write_new_private_file(path: &std::path::Path, bytes: &[u8]) -> Result<()> { @@ -6362,6 +6633,175 @@ mod tests { assert!(!mailbox_names_equal("Archive", "archive")); } + /// A reply goes to EVERY Reply-To address, and a sender whose display + /// name holds a comma or an `@` goes back into a draft as ONE mailbox. + #[test] + fn a_reply_goes_to_every_reply_to_and_keeps_quoted_names_whole() { + let raw = b"From: \"Blake, Mark\" \r\n\ + Reply-To: \"Blake, Mark\" , Desk \r\n\ + To: Me , colleague@example.com\r\n\ + Cc: \"x@y.example\" \r\n\ + Subject: Plans\r\n\ + Message-ID: \r\n\r\nbody"; + let source = parser::parse_rfc822(raw, 1, Vec::new(), None, "INBOX", "work", false, false) + .expect("parse source"); + let own: hashbrown::HashSet = ["me@example.com".to_string()].into_iter().collect(); + + let reply = derive_reply(&source, &own, ReplyMode::Reply); + assert_eq!( + reply.to, + [ + "\"Blake, Mark\" ", + "Desk " + ] + ); + assert!(reply.cc.is_empty()); + assert_eq!(reply.subject, "Re: Plans"); + assert_eq!(reply.in_reply_to.as_deref(), Some("plans@example.com")); + assert_eq!(reply.references, ["plans@example.com"]); + assert_eq!(reply.warning, None); + + let all = derive_reply(&source, &own, ReplyMode::ReplyAll); + assert_eq!( + all.to, + [ + "\"Blake, Mark\" ", + "Desk ", + "colleague@example.com" + ], + "the account's own address is never a recipient" + ); + assert_eq!(all.cc, ["\"x@y.example\" "]); + + let raw = + draft::compose_draft("s", "b", &all.to, &all.cc, &[], Some("me@example.com"), &[]) + .expect("every derived recipient composes"); + let parsed = mail_parser::MessageParser::default() + .parse(&raw) + .expect("parse draft"); + let to: Vec<_> = parsed.to().expect("To").iter().collect(); + assert_eq!(to.len(), 3, "no recipient split at a comma"); + assert_eq!(to[0].name.as_deref(), Some("Blake, Mark")); + assert_eq!(parsed.cc().expect("Cc").iter().count(), 1); + } + + #[test] + fn a_reply_without_reply_to_goes_to_the_sender_and_a_follow_up_to_the_original_recipient() { + let own: hashbrown::HashSet = ["me@example.com".to_string()].into_iter().collect(); + let incoming = parser::parse_rfc822( + b"From: Ann \r\nTo: me@example.com\r\nSubject: Hi\r\n\r\nx", + 1, + Vec::new(), + None, + "INBOX", + "work", + false, + false, + ) + .expect("parse"); + let reply = derive_reply(&incoming, &own, ReplyMode::Reply); + assert_eq!(reply.to, ["Ann "]); + assert!(reply.warning.is_some(), "no Message-ID means no threading"); + + let our_own = parser::parse_rfc822( + b"From: me@example.com\r\nTo: Ann \r\nSubject: Hi\r\n\r\nx", + 2, + Vec::new(), + None, + "Sent", + "work", + false, + false, + ) + .expect("parse"); + assert_eq!( + derive_reply(&our_own, &own, ReplyMode::Reply).to, + ["Ann "] + ); + } + + fn sent_row(email: &str, name: &str, day: u32) -> scan_cache::SenderRow { + scan_cache::SenderRow { + uid: day, + email: email.to_string(), + display_name: name.to_string(), + date: chrono::NaiveDate::from_ymd_opt(2026, 9, day) + .and_then(|date| date.and_hms_opt(12, 0, 0)) + .map(|time| time.and_utc()), + message_id: None, + } + } + + #[test] + fn identities_list_configured_addresses_first_then_what_sent_mail_used() { + let configured = [ + "mark@example.com".to_string(), + "sales@example.com".to_string(), + ]; + let sent = [ + sent_row("mark@example.com", "Mark Blake", 1), + sent_row("mark@example.com", "Mark", 2), + sent_row("mark@example.com", "Mark Blake", 3), + sent_row("once@example.org", "M", 4), + sent_row("old@example.net", "Mark Blake", 5), + sent_row("old@example.net", " ", 6), + sent_row("", "No Address", 7), + ]; + + let identities = aggregate_identities(&configured, &sent); + + let summary: Vec<(&str, bool, bool, usize)> = identities + .iter() + .map(|row| { + ( + row.address.as_str(), + row.configured, + row.primary, + row.messages, + ) + }) + .collect(); + assert_eq!( + summary, + [ + ("mark@example.com", true, true, 3), + ("sales@example.com", true, false, 0), + ("old@example.net", false, false, 2), + ("once@example.org", false, false, 1), + ], + "configured first even when unused, then the rest by use" + ); + assert_eq!(identities[0].display_names, ["Mark Blake", "Mark"]); + assert_eq!(identities[0].last_used, sent[2].date); + assert!(identities[1].display_names.is_empty() && identities[1].last_used.is_none()); + assert_eq!( + identities[2].display_names, + ["Mark Blake"], + "a blank name is no name" + ); + } + + #[test] + fn identities_are_bounded_in_rows_and_names() { + let configured = ["mark@example.com".to_string()]; + let mut sent: Vec<_> = (1..=60) + .map(|index| sent_row(&format!("a{index}@example.net"), "", 1)) + .collect(); + for (day, name) in ["A", "B", "C", "D", "E", "F"].into_iter().enumerate() { + sent.push(sent_row("mark@example.com", name, day as u32 + 1)); + } + + let identities = aggregate_identities(&configured, &sent); + + assert_eq!(identities.len(), MAX_IDENTITY_ROWS); + assert!(identities[0].primary, "the configured rows survive the cap"); + assert_eq!( + identities[0].display_names, + ["F", "E", "D", "C", "B"], + "equal use breaks to the newest, five at most" + ); + } + #[test] fn reply_subject_adds_exactly_one_prefix() { assert_eq!(reply_subject("Status"), "Re: Status"); @@ -6409,6 +6849,51 @@ mod tests { } } + /// `path` is where the file IS — absolute and canonical — not the bare + /// filename, which resolved against the server's working directory rather + /// than the output directory. + #[tokio::test] + async fn downloaded_attachments_report_their_absolute_path() { + let dir = std::env::temp_dir().join(format!("agentmail-att-{}", uuid::Uuid::new_v4())); + let attachments = vec![ + ( + "report.pdf".to_string(), + "application/pdf".to_string(), + b"%PDF".to_vec(), + ), + ( + "a/b.txt".to_string(), + "text/plain".to_string(), + b"hi".to_vec(), + ), + ]; + + let written = write_attachment_files(&dir, 7, &attachments) + .await + .expect("write into a fresh directory"); + + let canonical_dir = tokio::fs::canonicalize(&dir).await.expect("dir exists"); + assert_eq!(written.len(), 2); + for file in &written { + let path = std::path::Path::new(&file.path); + assert!(path.is_absolute(), "{path:?}"); + assert_eq!(path, canonical_dir.join(&file.filename)); + assert_eq!( + tokio::fs::read(path).await.expect("written").len(), + file.size + ); + } + assert_eq!(written[0].filename, "7_0_report.pdf"); + let again = write_attachment_files(&dir, 7, &attachments[..1]) + .await + .expect_err("an existing attachment is never overwritten"); + assert!( + again.to_string().contains("refusing to overwrite"), + "{again}" + ); + tokio::fs::remove_dir_all(dir).await.expect("cleanup"); + } + #[tokio::test] async fn archive_write_is_private_and_never_overwrites() { let dir = std::env::temp_dir().join(format!("agentmail-eml-{}", uuid::Uuid::new_v4())); @@ -6452,6 +6937,7 @@ mod tests { username: "Me@Example.COM".to_string(), email: None, aliases: Vec::new(), + display_name: None, password: None, tls: true, max_connections: None, @@ -7145,6 +7631,7 @@ mod tests { username: "login".to_string(), email: Some("me@example.com".to_string()), aliases: Vec::new(), + display_name: None, password: None, tls: true, max_connections: None, diff --git a/src/mailbox_catalog.rs b/src/mailbox_catalog.rs index fca33cd..dd81ede 100644 --- a/src/mailbox_catalog.rs +++ b/src/mailbox_catalog.rs @@ -6,7 +6,7 @@ use std::future::Future; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime}; use hashbrown::HashMap; use parking_lot::Mutex; @@ -27,7 +27,9 @@ struct AccountState { struct CatalogSnapshot { entries: Arc<[MailboxLayout]>, - loaded_at: Instant, + /// Wall-clock, so the TTL runs through sleep — see + /// [`crate::connection::wall_elapsed`]. + loaded_at: SystemTime, } /// Layout snapshots and per-account refresh locks. @@ -43,7 +45,7 @@ pub(crate) struct MailboxCatalog { impl MailboxCatalog { pub(crate) fn get(&self, account: &str) -> Option> { let started = Instant::now(); - let entries = self.get_at(account, started); + let entries = self.get_at(account, SystemTime::now()); if let Some(ref entries) = entries { trace_catalog("hit", started, 0, entries.len(), true); } @@ -65,7 +67,7 @@ impl MailboxCatalog { Fut: Future>>, { let started = Instant::now(); - if let Some(entries) = self.get_at(account, started) { + if let Some(entries) = self.get_at(account, SystemTime::now()) { trace_catalog("hit", started, 0, entries.len(), true); return Ok(entries); } @@ -73,7 +75,7 @@ impl MailboxCatalog { let refresh_lock = self.refresh_lock(account); let _refresh_guard = refresh_lock.lock().await; - if let Some(entries) = self.get_at(account, Instant::now()) { + if let Some(entries) = self.get_at(account, SystemTime::now()) { trace_catalog("shared_hit", started, 0, entries.len(), true); return Ok(entries); } @@ -87,7 +89,7 @@ impl MailboxCatalog { } }; let (entries, retained) = - self.store_if_generation(account, generation, entries, Instant::now()); + self.store_if_generation(account, generation, entries, SystemTime::now()); let cache_status = if retained { "miss" } else { "not_retained" }; trace_catalog(cache_status, started, 1, entries.len(), retained); Ok(entries) @@ -100,11 +102,12 @@ impl MailboxCatalog { state.snapshot = None; } - fn get_at(&self, account: &str, now: Instant) -> Option> { + fn get_at(&self, account: &str, now: SystemTime) -> Option> { let mut states = self.states.lock(); let state = states.get_mut(account)?; let is_fresh = state.snapshot.as_ref().is_some_and(|snapshot| { - now.saturating_duration_since(snapshot.loaded_at) < MAILBOX_CATALOG_TTL + crate::connection::wall_elapsed(snapshot.loaded_at, now) + .is_some_and(|age| age < MAILBOX_CATALOG_TTL) }); if !is_fresh { state.snapshot = None; @@ -138,7 +141,7 @@ impl MailboxCatalog { account: &str, generation: u64, entries: Vec, - loaded_at: Instant, + loaded_at: SystemTime, ) -> (Arc<[MailboxLayout]>, bool) { let cacheable = is_cacheable(&entries); let entries: Arc<[MailboxLayout]> = entries.into(); @@ -202,6 +205,25 @@ pub(crate) fn resolve_drafts(entries: &[MailboxLayout]) -> Option { ) } +/// Resolve a selectable RFC 6154 Sent mailbox, with fallbacks for servers that +/// omit the special-use attribute. EXACT names only: the substring fallback the +/// other roles use would match "sent" inside "Consent" or "Presentations". +pub(crate) fn resolve_sent(entries: &[MailboxLayout]) -> Option { + resolve_selectable_special_mailbox( + entries, + "sent", + &[ + "Sent", + "Sent Items", + "Sent Messages", + "Sent Mail", + "[Gmail]/Sent Mail", + "INBOX.Sent", + ], + &[], + ) +} + fn resolve_selectable_special_mailbox( entries: &[MailboxLayout], role: &str, @@ -303,6 +325,27 @@ mod tests { assert_eq!(resolve_trash(&entries).as_deref(), Some("Archive/Trash")); } + #[test] + fn sent_resolver_prefers_the_role_then_exact_names_never_a_substring() { + let entries = [ + entry("Sent Messages"), + special_entry("Gesendet", "sent", false), + ]; + assert_eq!(resolve_sent(&entries).as_deref(), Some("Gesendet")); + + let entries = [entry("Consent Forms"), entry("INBOX.Sent")]; + assert_eq!(resolve_sent(&entries).as_deref(), Some("INBOX.Sent")); + + let entries = [entry("Consent Forms"), entry("Presentations")]; + assert!(resolve_sent(&entries).is_none()); + + let entries = [special_entry("Sent", "sent", true)]; + assert!( + resolve_sent(&entries).is_none(), + "an unselectable Sent cannot be read" + ); + } + #[test] fn trash_resolver_returns_none_for_only_unselectable_candidates() { let entries = [special_entry("Trash", "trash", true)]; @@ -320,7 +363,7 @@ mod tests { #[test] fn fresh_snapshot_is_returned() { let catalog = MailboxCatalog::default(); - let now = Instant::now(); + let now = SystemTime::now(); let generation = catalog.generation("work"); catalog.store_if_generation("work", generation, vec![entry("INBOX")], now); @@ -332,7 +375,7 @@ mod tests { #[test] fn snapshot_expires_at_ttl_boundary() { let catalog = MailboxCatalog::default(); - let now = Instant::now(); + let now = SystemTime::now(); let generation = catalog.generation("work"); catalog.store_if_generation("work", generation, vec![entry("INBOX")], now); @@ -341,10 +384,24 @@ mod tests { assert!(result.is_none()); } + /// A snapshot whose load time is in the future — the clock was set back + /// since — has no knowable age, so it is refreshed rather than trusted. + #[test] + fn snapshot_loaded_after_now_is_expired() { + let catalog = MailboxCatalog::default(); + let now = SystemTime::now(); + let generation = catalog.generation("work"); + catalog.store_if_generation("work", generation, vec![entry("INBOX")], now); + + let result = catalog.get_at("work", now - Duration::from_secs(1)); + + assert!(result.is_none()); + } + #[test] fn invalidation_is_scoped_to_one_account() { let catalog = MailboxCatalog::default(); - let now = Instant::now(); + let now = SystemTime::now(); for account in ["work", "personal"] { let generation = catalog.generation(account); catalog.store_if_generation(account, generation, vec![entry("INBOX")], now); @@ -397,7 +454,7 @@ mod tests { let oversized = vec![entry("x".repeat(MAX_LAYOUT_BYTES_PER_ACCOUNT + 1))]; let (entries, retained) = - catalog.store_if_generation("work", generation, oversized, Instant::now()); + catalog.store_if_generation("work", generation, oversized, SystemTime::now()); assert_eq!(entries.len(), 1); assert!(!retained); @@ -431,7 +488,7 @@ mod tests { async fn failed_refresh_does_not_serve_expired_snapshot() { let catalog = MailboxCatalog::default(); let generation = catalog.generation("work"); - let expired_at = Instant::now() - MAILBOX_CATALOG_TTL; + let expired_at = SystemTime::now() - MAILBOX_CATALOG_TTL; catalog.store_if_generation("work", generation, vec![entry("stale")], expired_at); let result = catalog @@ -493,8 +550,12 @@ mod tests { let generation = catalog.generation("work"); catalog.invalidate("work"); - let (entries, retained) = - catalog.store_if_generation("work", generation, vec![entry("stale")], Instant::now()); + let (entries, retained) = catalog.store_if_generation( + "work", + generation, + vec![entry("stale")], + SystemTime::now(), + ); assert_eq!(entries.len(), 1); assert!(!retained); diff --git a/src/main.rs b/src/main.rs index 39e8de9..7e9dfdf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -165,6 +165,10 @@ enum CliCommand { CreateDraft { #[arg(long)] account: String, + /// Sender, "Name
" or "address"; must be one of the account's + /// own addresses. Defaults to its primary address and display name. + #[arg(long)] + from: Option, #[arg(long)] subject: String, #[arg(long)] @@ -410,6 +414,7 @@ async fn main() -> Result<(), Box> { } CliCommand::CreateDraft { account, + from, subject, body, to, @@ -418,7 +423,21 @@ async fn main() -> Result<(), Box> { } => { let mk = agentmail::Agentmail::from_default_config()?; let value = mk - .create_draft(&account, &subject, &body, &to, &cc, &bcc, &[]) + .create_draft_with_headers( + &account, + from.as_deref(), + &subject, + &body, + &to, + &cc, + &bcc, + &[], + None, + &[], + &[], + None, + agentmail::draft::BodyFormat::default(), + ) .await?; println!("{}", serde_json::to_string_pretty(&value)?); Ok(()) @@ -650,6 +669,48 @@ fn shell_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\"'\"'")) } +/// A TOML string literal for `value`, written by the TOML serializer. +/// +/// Never `{:?}`: Rust's debug escapes are not TOML's (`\u{301}` versus +/// `\u0301`), so a name typed with a decomposed accent produced a config file +/// that failed to parse. +fn toml_string(value: &str) -> String { + toml::Value::String(value.to_string()).to_string() +} + +/// The `[accounts.]` table for one validated account. +fn account_section( + account_name: &str, + account: &agentmail::AccountConfig, + password_toml: &str, +) -> String { + let mut section = format!("[accounts.{}]\n", toml_string(account_name)); + section.push_str(&format!("host = {}\n", toml_string(&account.host))); + if account.port != 993 { + section.push_str(&format!("port = {}\n", account.port)); + } + section.push_str(&format!("username = {}\n", toml_string(&account.username))); + if let Some(email) = &account.email { + section.push_str(&format!("email = {}\n", toml_string(email))); + } + if !account.aliases.is_empty() { + let aliases = toml::Value::Array( + account + .aliases + .iter() + .cloned() + .map(toml::Value::String) + .collect(), + ); + section.push_str(&format!("aliases = {aliases}\n")); + } + if let Some(display_name) = &account.display_name { + section.push_str(&format!("display_name = {}\n", toml_string(display_name))); + } + section.push_str(&format!("{password_toml}\n")); + section +} + struct TemporaryConfigFile { path: std::path::PathBuf, armed: bool, @@ -808,12 +869,13 @@ async fn configure_account(provider: Option<&str>) -> Result<(), Box>(); + let display_name = prompt("Your name (shown on drafts, optional): ")?; let mut account_config = agentmail::AccountConfig::new(&host, &username) .with_port(port) @@ -821,6 +883,9 @@ async fn configure_account(provider: Option<&str>) -> Result<(), Box) -> Result<(), Box) -> Result<(), Box { let pw = prompt_secret("Password: ")?; - (format!("password.raw = {:?}", pw), false) + (format!("password.raw = {}", toml_string(&pw)), false) } "keyring" | "1" => { // keyring (default) ( - format!("password.keyring = {:?}", format!("mail.{username}")), + format!( + "password.keyring = {}", + toml_string(&format!("mail.{username}")) + ), true, ) } @@ -881,19 +947,7 @@ async fn configure_account(provider: Option<&str>) -> Result<(), Box) -> Result<(), Box", + "--subject", + "Hi", + "--body", + "Hello", + "--to", + "you@example.com", + ]) + .expect("valid command"); + + match cli.command.expect("subcommand") { + CliCommand::CreateDraft { from, .. } => { + assert_eq!(from.as_deref(), Some("Mark Blake ")); + } + _ => panic!("expected create-draft"), + } + } + #[test] fn account_names_reject_toml_structure_characters() { assert!(valid_account_name("work_2026-07")); diff --git a/src/mcp/args.rs b/src/mcp/args.rs index cc21aae..22c2412 100644 --- a/src/mcp/args.rs +++ b/src/mcp/args.rs @@ -16,6 +16,19 @@ pub(super) fn default_false() -> bool { #[schemars(description = "No arguments.")] pub(super) struct ListAccountsArgs {} +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(description = "Arguments for listing the addresses an account sends as.")] +pub(super) struct ListIdentitiesArgs { + #[schemars(description = "Account name (required).")] + pub(super) account: String, + #[schemars( + range(min = 1, max = 1000), + description = "How many of the newest Sent messages to read. Defaults to 200; maximum 1000." + )] + pub(super) sent_messages: Option, +} + #[derive(Debug, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[schemars(description = "Arguments for listing mailboxes.")] @@ -224,6 +237,11 @@ pub(super) struct CreateDraftArgs { )] pub(super) account: String, #[serde(default)] + #[schemars( + description = "Sender, as `Name
` or a bare address. The address must be one of this account's own — list_accounts lists them — or the draft is refused; the name defaults to the account's display name. Omit it to send as the account's primary address, or, for a reply, as the address the original was sent to." + )] + pub(super) from: Option, + #[serde(default)] #[schemars(description = "Draft subject line.")] pub(super) subject: String, #[serde(default)] @@ -326,6 +344,11 @@ pub(super) struct UpdateDraftArgs { #[schemars(range(min = 1))] pub(super) expected_uid_validity: u32, #[serde(default)] + #[schemars( + description = "Sender, as `Name
` or a bare address. The address must be one of this account's own — list_accounts lists them — or the update is refused; the name defaults to the account's display name. Omit it to keep the draft's current sender when that is one of the account's addresses, otherwise the primary address." + )] + pub(super) from: Option, + #[serde(default)] pub(super) subject: String, #[serde(default)] pub(super) body: String, @@ -1002,7 +1025,9 @@ pub(super) struct FindAttachmentsPromptArgs { #[derive(Debug, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(super) struct ComposeEmailArgs { - #[schemars(description = "Account name to send from.")] + #[schemars( + description = "Account name. The draft is saved to this account's Drafts, From one of its addresses." + )] pub(super) account: String, #[schemars(description = "Recipient email address.")] pub(super) to: Option, diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 6e0a6cd..35ea6fd 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -439,7 +439,8 @@ impl ServerHandler for AgentMailServer { \n\ The configured accounts are already listed in every `account` argument's\n\ schema — choose one from there. Call `list_accounts` only when you need to know\n\ - which account is the DEFAULT.\n\ + which account is the DEFAULT, or which addresses an account sends as.\n\ + `list_identities` adds the From addresses its recent Sent mail used.\n\ \n\ `list_mailboxes` takes one account and returns selectable mailboxes only, 100\n\ per page.\n\ @@ -470,6 +471,12 @@ impl ServerHandler for AgentMailServer { \n\ `create_draft` and `update_draft`. Both accept Reply-To, Bcc,\n\ attachments and RFC threading headers.\n\ + \n\ + A draft is From the account's primary address under its display name. Pass\n\ + `from` (`Name
` or an address) to use another of the account's own\n\ + addresses — any other address is refused. A reply is From the address the\n\ + original was sent to; `update_draft` keeps the draft's current sender. The\n\ + result's `from` is the sender actually written.\n\ \n\ To reply, give `create_draft` a `replyToMessage` — the mailbox, uid,\n\ expectedUidValidity and mode of the live message. It derives the\n\ @@ -909,6 +916,7 @@ mod tests { username: format!("{name}@example.com"), email: None, aliases: Vec::new(), + display_name: None, password: None, tls: true, max_connections: None, @@ -1176,7 +1184,7 @@ mod tests { let tools = AgentMailServer::tool_router().list_all(); assert_eq!( tools.len(), - 35, + 36, "tool count drifted — update docs and tests" ); for tool in &tools { diff --git a/src/mcp/prompts.rs b/src/mcp/prompts.rs index d1de3a6..504e723 100644 --- a/src/mcp/prompts.rs +++ b/src/mcp/prompts.rs @@ -96,7 +96,9 @@ impl AgentMailServer { instructions.push_str( " Ask me what I want to say, help me write the body, then use create_draft \ (with optional attachments) to save it. Show me a preview before saving; create_draft resolves \ - the proper Drafts mailbox and applies the Draft flag itself.", + the proper Drafts mailbox and applies the Draft flag itself. It is From the account's primary \ + address under its display name; if I want another of the account's addresses (list_accounts \ + shows them), pass it as `from`.", ); vec![PromptMessage::new_text(Role::User, instructions)] } diff --git a/src/mcp/resources.rs b/src/mcp/resources.rs index 1d224f7..8d6f0c4 100644 --- a/src/mcp/resources.rs +++ b/src/mcp/resources.rs @@ -1206,7 +1206,7 @@ mod tests { "uid": 42, "subject": "Quarterly report", "sender": "Alice ", - "replyTo": "", + "replyTo": [], "to": ["me@example.com"], "cc": [], "mailbox": "INBOX", diff --git a/src/mcp/tasks.rs b/src/mcp/tasks.rs index 8441268..bb9ca87 100644 --- a/src/mcp/tasks.rs +++ b/src/mcp/tasks.rs @@ -79,6 +79,16 @@ impl TaskCompletion { self.changed.notify_waiters(); } + /// Publish the result of a task removed at its TTL while still running. + /// The worker is aborted, so it will never publish, and without this the + /// status watcher and any blocked `tasks/result` would wait forever. A + /// worker that finished before the prune still wins: this publishes once. + fn expire(&self, task_id: &str) { + self.complete(Ok(CallToolResult::error(vec![ContentBlock::text( + format!("Task {task_id} expired before completion and was cancelled."), + )]))); + } + fn snapshot(&self) -> Option> { self.result.lock().clone() } @@ -287,6 +297,7 @@ impl TaskManager { && !is_terminal(&managed.meta.status) { managed.cancel.cancel(); + managed.completion.expire(&task_id); managed.handle.abort(); } self.task_records.remove(&task_id); @@ -630,6 +641,37 @@ mod tests { ); } + /// The status watcher waits on the completion, and an expired task's + /// worker is aborted before it can publish — so expiry must publish, or + /// the watcher (and any blocked `tasks/result`) waits forever. + #[tokio::test] + async fn prune_expired_should_publish_a_result_for_running_work() { + let clock = TestClock::new(); + let mut manager = manager(&clock); + commit_running(&mut manager, "abandoned"); + let completion = manager + .task_completion("abandoned") + .expect("task should be present"); + let watcher = tokio::spawn({ + let completion = Arc::clone(&completion); + async move { completion.wait(&CancellationToken::new()).await } + }); + + clock.advance(TASK_TTL_MS); + manager.prune_expired(); + let result = tokio::time::timeout(std::time::Duration::from_secs(5), watcher) + .await + .expect("expiry must wake the watcher") + .expect("watcher should not panic") + .expect("an expired task ends with a result, not an error"); + + assert_eq!(result.is_error, Some(true)); + assert!( + format!("{:?}", result.content).contains("expired"), + "{result:?}" + ); + } + #[tokio::test] async fn expiry_should_be_measured_from_creation_not_last_update() { let clock = TestClock::new(); diff --git a/src/mcp/tools_read.rs b/src/mcp/tools_read.rs index b988b87..6858579 100644 --- a/src/mcp/tools_read.rs +++ b/src/mcp/tools_read.rs @@ -4,11 +4,11 @@ use super::AgentMailServer; use super::args::*; use super::wire::{ CheckConnectionOutput, FindAttachmentsOutput, GetMessagesOutput, ListAccountsOutput, - ListCapabilitiesOutput, ListFlagsOutput, ListMailboxesOutput, ListPendingMovesOutput, - SearchMessagesOutput, TopDomainsOutput, TopMailingListsOutput, TopSendersOutput, - TopSubscriptionsOutput, compact_result, tool_error_result, + ListCapabilitiesOutput, ListFlagsOutput, ListIdentitiesOutput, ListMailboxesOutput, + ListPendingMovesOutput, SearchMessagesOutput, TopDomainsOutput, TopMailingListsOutput, + TopSendersOutput, TopSubscriptionsOutput, compact_result, tool_error_result, }; -use super::{Pagination, make_cancel_fn, make_progress_fn}; +use super::{Pagination, bounded_usize, make_cancel_fn, make_progress_fn}; use rmcp::{ ErrorData as McpError, Peer, RoleServer, handler::server::wrapper::Parameters, @@ -35,7 +35,7 @@ impl AgentMailServer { #[tool( name = "list_accounts", output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid list_accounts output schema"), - description = "Return configured IMAP account names, each with whether it is the default. The names themselves are already in every tool's `account` enum and are listed as email://{account} resources — call this only when the DEFAULT matters or to confirm what is configured, not to discover a selector.", + description = "Return configured IMAP account names, each with whether it is the default, its display name, and the addresses it sends as (primary first) — the addresses a draft's `from` may use. The names themselves are already in every tool's `account` enum and are listed as email://{account} resources — call this when the DEFAULT or the sending addresses matter, not to discover a selector.", annotations( title = "List Accounts", read_only_hint = true, @@ -52,6 +52,40 @@ impl AgentMailServer { } } + #[tool( + name = "list_identities", + output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid list_identities output schema"), + description = "List the addresses one account sends as: its configured addresses (configured=true — the only ones a draft's `from` may use, the primary first) and the From addresses its recent Sent mail actually used, each with a message count, the display names it went out under, and when it was last used. Reads the newest sentMessages (default 200, max 1000) messages of the Sent mailbox with BODY.PEEK, so nothing is marked read; sentMailbox is null when the account has none, and the configured addresses are still listed. An address seen in Sent but not configured cannot be used as `from` until it is added to the account as an alias.", + annotations( + title = "List Sender Identities", + read_only_hint = true, + idempotent_hint = true + ) + )] + async fn list_identities_tool( + &self, + Parameters(args): Parameters, + ) -> Result { + if args.account.trim().is_empty() { + return Err(McpError::invalid_params("account is required", None)); + } + let sent_messages = bounded_usize( + args.sent_messages, + crate::DEFAULT_IDENTITY_SCAN, + 1, + crate::MAX_IDENTITY_SCAN, + "sentMessages", + )?; + match self + .agentmail + .list_identities(&args.account, sent_messages) + .await + { + Ok(data) => compact_result(ListIdentitiesOutput::from(data)), + Err(e) => Ok(tool_error_result(&e)), + } + } + #[tool( name = "list_mailboxes", output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid list_mailboxes output schema"), diff --git a/src/mcp/tools_write.rs b/src/mcp/tools_write.rs index 19dc8b0..44258b6 100644 --- a/src/mcp/tools_write.rs +++ b/src/mcp/tools_write.rs @@ -1101,7 +1101,7 @@ impl AgentMailServer { #[tool( name = "create_draft", output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid create_draft output schema"), - description = "Create and save a draft — a fresh message, or a reply to a live one. Resolves the account's selectable \\Drafts special-use mailbox, falls back to Drafts and creates it when needed, then APPENDs the message with the \\Draft flag. Compose fresh by giving recipients: at least one of to, cc or bcc. Reply instead by giving replyToMessage {mailbox, uid, expectedUidValidity, mode} from a discovery result — that derives the recipients (Reply-To before From, excluding this account's own addresses and aliases), a Re: subject you may override, and the RFC In-Reply-To/References headers; to, cc, inReplyTo and references must then be omitted, and bcc is still never inferred. Subject and body are optional; attachments may reference local file paths. The body is read as Markdown and sent as multipart/alternative — the text exactly as written, plus an HTML rendering — so **bold**, lists, links and tables arrive formatted rather than as literal syntax. Raw HTML in the body is escaped, never rendered. Set plainTextOnly=true for a single unrendered text/plain part. Returns the new draft's uid and uidValidity when the server allows recovering them, and links the draft as a resource_link. A draft's UID is NOT durable — re-saving it (here, or in any other mail client) appends a new message and expunges the old one, and some servers discard an APPENDed draft outright. Re-read the link from a fresh get_messages rather than reusing one from an earlier turn. This tool never sends mail.", + description = "Create and save a draft — a fresh message, or a reply to a live one. Resolves the account's selectable \\Drafts special-use mailbox, falls back to Drafts and creates it when needed, then APPENDs the message with the \\Draft flag. Compose fresh by giving recipients: at least one of to, cc or bcc. Reply instead by giving replyToMessage {mailbox, uid, expectedUidValidity, mode} from a discovery result — that derives the recipients (Reply-To before From, excluding this account's own addresses and aliases), a Re: subject you may override, and the RFC In-Reply-To/References headers; to, cc, inReplyTo and references must then be omitted, and bcc is still never inferred. Subject and body are optional; attachments may reference local file paths. The body is read as Markdown and sent as multipart/alternative — the text exactly as written, plus an HTML rendering — so **bold**, lists, links and tables arrive formatted rather than as literal syntax. Raw HTML in the body is escaped, never rendered. Set plainTextOnly=true for a single unrendered text/plain part. The draft is From the account's primary address under its display name; pass `from` (Name
or an address) to use another of the account's own addresses — list_accounts lists them, and any other address is refused. A reply is From the address the original was sent to unless `from` says otherwise. Returns the sender actually written as `from`, plus the new draft's uid and uidValidity when the server allows recovering them, and links the draft as a resource_link. A draft's UID is NOT durable — re-saving it (here, or in any other mail client) appends a new message and expunges the old one, and some servers discard an APPENDed draft outright. Re-read the link from a fresh get_messages rather than reusing one from an earlier turn. This tool never sends mail.", annotations( title = "Create Draft", read_only_hint = false, @@ -1143,6 +1143,7 @@ impl AgentMailServer { .agentmail .create_reply_draft( &args.account, + args.from.as_deref(), source.mailbox.trim(), source.uid, source.expected_uid_validity, @@ -1172,6 +1173,7 @@ impl AgentMailServer { .agentmail .create_draft_with_headers( &args.account, + args.from.as_deref(), args.subject.trim(), &args.body, &args.to, @@ -1194,7 +1196,7 @@ impl AgentMailServer { #[tool( name = "update_draft", output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid update_draft output schema"), - description = "Replace one live IMAP draft with a complete new draft specification. Requires mailbox, uid, and expectedUidValidity; verifies the target still has the \\Draft flag. Uses RFC 8508 REPLACE where the server has it and otherwise emulates it, so this succeeds on servers without REPLACE (Gmail and iCloud among them) — do not hand-roll create_draft + delete_messages. Returns the NEW uid and uidValidity: a replaced draft always has a new identity, so discard the old one. A `warning` field appears only if the superseded draft could not be removed. Attachments are a complete replacement list, and this tool never sends mail. The body is read as Markdown and sent as multipart/alternative (the text as written plus an HTML rendering); raw HTML in it is escaped, never rendered. Set plainTextOnly=true for a single unrendered text/plain part.", + description = "Replace one live IMAP draft with a complete new draft specification. Requires mailbox, uid, and expectedUidValidity; verifies the target still has the \\Draft flag. Uses RFC 8508 REPLACE where the server has it and otherwise emulates it, so this succeeds on servers without REPLACE (Gmail and iCloud among them) — do not hand-roll create_draft + delete_messages. Returns the NEW uid and uidValidity: a replaced draft always has a new identity, so discard the old one. A `warning` field appears only if the superseded draft could not be removed. Attachments are a complete replacement list, and this tool never sends mail. The draft keeps its current sender when that is one of the account's addresses (so one chosen in another mail client survives); `from` replaces it with another of the account's own addresses, and the result's `from` is the sender written. The body is read as Markdown and sent as multipart/alternative (the text as written plus an HTML rendering); raw HTML in it is escaped, never rendered. Set plainTextOnly=true for a single unrendered text/plain part.", annotations( title = "Update Draft Atomically", read_only_hint = false, @@ -1221,6 +1223,7 @@ impl AgentMailServer { .agentmail .update_draft( &args.account, + args.from.as_deref(), args.mailbox.trim(), args.uid, args.expected_uid_validity, diff --git a/src/mcp/wire.rs b/src/mcp/wire.rs index 6615999..bac06c5 100644 --- a/src/mcp/wire.rs +++ b/src/mcp/wire.rs @@ -202,6 +202,12 @@ fn redact_urls(value: Option) -> Option { pub(super) struct AccountOutput { pub(super) name: String, pub(super) is_default: bool, + /// The sender name drafts carry, when one is configured. + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) display_name: Option, + /// The addresses this account sends as, primary first. A draft's `from` + /// must be one of them. + pub(super) addresses: Vec, } #[derive(Debug, Clone, Serialize, JsonSchema)] @@ -219,6 +225,8 @@ impl From for ListAccountsOutput { .map(|account| AccountOutput { name: account.name, is_default: account.is_default, + display_name: account.display_name, + addresses: account.addresses, }) .collect(), } @@ -227,6 +235,61 @@ impl From for ListAccountsOutput { impl WireOutput for ListAccountsOutput {} +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(inline)] +pub(super) struct SenderIdentityOutput { + pub(super) address: String, + /// One of the account's own addresses — usable as a draft's `from`. + pub(super) configured: bool, + /// The account's primary address, a draft's default sender. + pub(super) primary: bool, + /// Names Sent mail went out under from this address, most used first. + pub(super) display_names: Vec, + /// Scanned Sent messages from this address. + pub(super) messages: usize, + /// Date of the newest of them, RFC 3339. + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) last_used: Option, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub(super) struct ListIdentitiesOutput { + pub(super) account: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) display_name: Option, + /// The Sent mailbox that was read; null when the account has none. + pub(super) sent_mailbox: Option, + pub(super) scanned_messages: usize, + pub(super) identities: Vec, +} + +impl From for ListIdentitiesOutput { + fn from(value: crate::ListIdentitiesResponse) -> Self { + Self { + account: value.account, + display_name: value.display_name, + sent_mailbox: value.sent_mailbox, + scanned_messages: value.scanned_messages, + identities: value + .identities + .into_iter() + .map(|identity| SenderIdentityOutput { + address: identity.address, + configured: identity.configured, + primary: identity.primary, + display_names: identity.display_names, + messages: identity.messages, + last_used: identity.last_used.map(|date| date.to_rfc3339()), + }) + .collect(), + } + } +} + +impl WireOutput for ListIdentitiesOutput {} + #[derive(Debug, Clone, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] #[schemars(inline)] @@ -1654,6 +1717,9 @@ pub(super) struct CreateDraftOutput { pub(super) created: bool, pub(super) account: String, pub(super) drafts_mailbox: String, + /// The sender the draft was written with, `Name
` or `address` — + /// resolved from the account, not echoed from the request. + pub(super) from: String, pub(super) attachment_count: usize, pub(super) reply_to_count: usize, pub(super) threading_applied: bool, @@ -1676,6 +1742,7 @@ impl From for CreateDraftOutput { created: value.created, account: value.account, drafts_mailbox: value.drafts_mailbox, + from: value.from, attachment_count: value.attachments.len(), reply_to_count: value.recipients.reply_to.len(), threading_applied: value.threading_applied, @@ -1721,6 +1788,9 @@ pub(super) struct UpdateDraftOutput { pub(super) updated: bool, pub(super) account: String, pub(super) drafts_mailbox: String, + /// The sender the replacement was written with, `Name
` or + /// `address` — resolved from the account, not echoed from the request. + pub(super) from: String, #[schemars(range(min = 1))] pub(super) previous_uid_validity: u32, #[schemars(range(min = 1))] @@ -1743,6 +1813,7 @@ impl From for UpdateDraftOutput { updated: value.updated, account: value.account, drafts_mailbox: value.drafts_mailbox, + from: value.from, previous_uid_validity: value.previous_uid_validity, previous_uid: value.previous_uid, uid_validity: value.uid_validity, @@ -2249,6 +2320,8 @@ mod tests { accounts: vec![AccountOutput { name: "work".to_string(), is_default: true, + display_name: None, + addresses: vec!["me@example.com".to_string()], }], }; assert!(output.resource_uris().is_empty()); @@ -2263,6 +2336,8 @@ mod tests { accounts: vec![AccountOutput { name: "work".to_string(), is_default: true, + display_name: None, + addresses: vec!["me@example.com".to_string()], }], }) .expect("result should serialize"); @@ -2279,6 +2354,8 @@ mod tests { .map(|index| AccountOutput { name: format!("account-{index}"), is_default: index == 0, + display_name: None, + addresses: Vec::new(), }) .collect(), }) @@ -2330,6 +2407,8 @@ mod tests { assert_ref_free::(); assert_ref_free::(); assert_ref_free::(); + assert_ref_free::(); + assert_ref_free::(); assert_ref_free::(); assert_ref_free::(); assert_ref_free::(); diff --git a/src/parser.rs b/src/parser.rs index 9caa21b..9470302 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -30,7 +30,7 @@ pub fn parse_rfc822( let subject = parsed.subject().unwrap_or("").to_string(); let sender = format_address(parsed.from()); - let reply_to = format_address(parsed.reply_to()); + let reply_to = format_address_list(parsed.reply_to()); let to = format_address_list(parsed.to()); let cc = format_address_list(parsed.cc()); let bcc = format_address_list(parsed.bcc()); @@ -363,13 +363,51 @@ fn format_address_list(addr: Option<&mail_parser::Address<'_>>) -> Vec { } fn format_single_addr(a: &mail_parser::Addr<'_>) -> String { - let name = a.name.as_deref().unwrap_or(""); - let email = a.address.as_deref().unwrap_or(""); + format_name_addr( + a.name.as_deref().unwrap_or(""), + a.address.as_deref().unwrap_or(""), + ) +} + +/// `Name `, or the bare `email` when there is no name — written so it +/// parses back as exactly ONE mailbox. +/// +/// A display name holding an RFC 5322 special (`,` `.` `@` `"` `<` …) is +/// emitted as a quoted-string with `\` and `"` escaped. Unquoted, the sender +/// `"Blake, Mark" ` came out as `Blake, Mark ` — two mailboxes to +/// any parser, and a refused address when a reply fed it back into a draft. +/// Control characters, CR and LF included, cannot appear in a header phrase +/// and are dropped. Our own quoting rather than lettre's `Mailbox` `Display`, +/// which fails outright on a CR or LF in the name. +pub(crate) fn format_name_addr(name: &str, email: &str) -> String { + let name: String = name + .chars() + .map(|c| if c == '\t' { ' ' } else { c }) + .filter(|c| !c.is_control()) + .collect(); + let name = name.trim(); if name.is_empty() { - email.to_string() - } else { - format!("{} <{}>", name, email) + return email.to_string(); + } + if name.chars().all(|c| c == ' ' || is_atext(c)) { + return format!("{name} <{email}>"); + } + let mut quoted = String::with_capacity(name.len() + 2); + quoted.push('"'); + for c in name.chars() { + if matches!(c, '\\' | '"') { + quoted.push('\\'); + } + quoted.push(c); } + quoted.push('"'); + format!("{quoted} <{email}>") +} + +/// RFC 5322 `atext`, plus the non-ASCII letters RFC 6532 admits: everything +/// that may stand in an unquoted display name between spaces. +fn is_atext(c: char) -> bool { + c.is_ascii_alphanumeric() || "!#$%&'*+-/=?^_`{|}~".contains(c) || !c.is_ascii() } #[cfg(test)] @@ -410,6 +448,72 @@ mod tests { ); } + /// Every formatted address parses back as exactly ONE mailbox with the + /// same name — the property a reply depends on when it feeds a sender + /// back into a draft. + #[test] + fn format_name_addr_quotes_names_that_hold_specials() { + for (name, expected) in [ + ("", "m@x.example"), + ("Mark Blake", "Mark Blake "), + ("José Ñúñez", "José Ñúñez "), + ("Blake, Mark", "\"Blake, Mark\" "), + ("m@x.example", "\"m@x.example\" "), + ("J. Smith", "\"J. Smith\" "), + ( + "Mark \"Shark\" Blake", + "\"Mark \\\"Shark\\\" Blake\" ", + ), + ("back\\slash", "\"back\\\\slash\" "), + ( + "Mark\r\nBcc: evil@x.example", + "\"MarkBcc: evil@x.example\" ", + ), + ] { + let formatted = format_name_addr(name, "m@x.example"); + assert_eq!(formatted, expected, "name {name:?}"); + let mailbox: lettre::message::Mailbox = formatted + .parse() + .unwrap_or_else(|error| panic!("{formatted:?} must parse: {error}")); + let expected_name: String = name.chars().filter(|c| !c.is_control()).collect(); + assert_eq!( + mailbox.name.as_deref().unwrap_or(""), + expected_name, + "name {name:?}" + ); + } + } + + #[test] + fn every_reply_to_address_is_kept() { + let raw = b"From: \"Blake, Mark\" \r\n\ + Reply-To: Desk , \"Blake, Mark\" \r\n\ + Subject: s\r\n\r\nbody"; + let message = + parse_rfc822(raw, 1, Vec::new(), None, "INBOX", "work", false, false).expect("parse"); + + assert_eq!(message.sender, "\"Blake, Mark\" "); + assert_eq!( + message.reply_to, + [ + "Desk ", + "\"Blake, Mark\" " + ] + ); + let without = parse_rfc822( + b"From: a@b.example\r\n\r\nx", + 2, + Vec::new(), + None, + "INBOX", + "work", + false, + false, + ) + .expect("parse"); + assert!(without.reply_to.is_empty()); + } + /// Empty (whitespace-only) header values are skipped — no key is created, /// so no owned key is allocated for them. #[test] diff --git a/src/provider.rs b/src/provider.rs index e8065c8..05b7eec 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -74,6 +74,7 @@ impl MailProvider { username: username.to_string(), email: None, aliases: Vec::new(), + display_name: None, password, tls: true, max_connections: None, diff --git a/src/types.rs b/src/types.rs index 2952836..6606175 100644 --- a/src/types.rs +++ b/src/types.rs @@ -13,6 +13,13 @@ pub struct AccountInfo { pub host: String, pub username: String, pub is_default: bool, + /// The sender name drafts carry, when one is configured. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// The addresses this account sends as — the primary first, then aliases. + /// A draft's `from` must be one of them. + #[serde(default)] + pub addresses: Vec, } /// A mailbox on the server with message counts. @@ -80,7 +87,9 @@ pub struct MessageInfo { pub uid: u32, pub subject: String, pub sender: String, - pub reply_to: String, + /// Every `Reply-To` address, in header order; empty when there is none. + #[serde(default)] + pub reply_to: Vec, pub to: Vec, pub cc: Vec, pub mailbox: String, @@ -218,6 +227,47 @@ pub struct ListAccountsResponse { pub accounts: Vec, } +/// One address an account sends as, configured or seen in its Sent mail. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(inline)] +pub struct SenderIdentity { + /// Canonical (lowercased) address. + pub address: String, + /// One of the account's configured addresses — a draft may be `from` it. + /// An address seen only in Sent must be added as an alias first. + pub configured: bool, + /// The account's primary address, a draft's default sender. + pub primary: bool, + /// Names the scanned Sent mail went out under from this address, most used + /// first (at most five). + pub display_names: Vec, + /// Scanned Sent messages from this address; 0 for a configured address + /// that does not appear in them. + pub messages: usize, + /// Date of the newest scanned message from this address. + #[serde(skip_serializing_if = "Option::is_none")] + pub last_used: Option>, +} + +/// Response for list_identities. +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ListIdentitiesResponse { + pub account: String, + /// The configured sender name, when there is one. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// The Sent mailbox that was read; `None` when the account has none, in + /// which case only the configured addresses are reported. + pub sent_mailbox: Option, + /// How many of the newest Sent messages were read. + pub scanned_messages: usize, + /// Configured addresses first (primary, then aliases), then addresses seen + /// only in Sent, most used first. + pub identities: Vec, +} + /// Response for list_mailboxes. #[derive(Debug, Clone, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] @@ -881,6 +931,9 @@ pub struct CreateDraftResponse { pub created: bool, pub account: String, pub drafts_mailbox: String, + /// The sender written to the draft's `From`, as `Name ` or `addr` — + /// what was resolved, not what was asked for. + pub from: String, pub subject: String, pub recipients: DraftRecipients, #[serde(skip_serializing_if = "Option::is_none")] @@ -932,6 +985,9 @@ pub struct UpdateDraftResponse { pub updated: bool, pub account: String, pub drafts_mailbox: String, + /// The sender written to the replacement's `From`, as `Name ` or + /// `addr` — what was resolved, not what was asked for. + pub from: String, pub previous_uid_validity: u32, pub previous_uid: u32, #[serde(skip_serializing_if = "Option::is_none")] @@ -951,7 +1007,9 @@ pub struct UpdateDraftResponse { #[schemars(inline)] pub struct DownloadedFile { pub index: usize, + /// `{uid}_{index}_{name}`, the name sanitized. pub filename: String, + /// Absolute, canonical path of the written file. pub path: String, pub content_type: String, pub size: usize, diff --git a/tests/mcp_integration.rs b/tests/mcp_integration.rs index 64b38a7..d42b74c 100644 --- a/tests/mcp_integration.rs +++ b/tests/mcp_integration.rs @@ -24,7 +24,6 @@ struct McpClient { impl McpClient { async fn start() -> Self { - let (client_io, server_io) = tokio::io::duplex(1024 * 1024); let config = Config::from_accounts(vec![( "dummy".to_string(), AccountConfig { @@ -33,12 +32,18 @@ impl McpClient { username: "dummy@example.invalid".to_string(), email: None, aliases: Vec::new(), + display_name: None, password: Some(Secret::new_raw("unused")), tls: true, max_connections: None, auth: agentmail::AuthMethod::Password, }, )]); + Self::start_with(config).await + } + + async fn start_with(config: Config) -> Self { + let (client_io, server_io) = tokio::io::duplex(1024 * 1024); tokio::spawn(async move { let _ = agentmail::mcp::serve_on(server_io, Agentmail::new(config)).await; }); @@ -307,13 +312,13 @@ async fn initialize_reports_capabilities_and_identity() { } #[tokio::test] -async fn tools_list_has_35_annotated_tools() { +async fn tools_list_has_36_annotated_tools() { let mut client = McpClient::start().await; let resp = client.request("tools/list", json!({})).await; let tools = resp["result"]["tools"].as_array().expect("tools array"); assert_eq!( tools.len(), - 35, + 36, "tool count drifted — update docs and tests" ); @@ -474,6 +479,169 @@ async fn list_accounts_schema_omits_connection_credentials() { } } +/// An account whose IMAP login is NOT one of its addresses, so a test can tell +/// "the addresses it sends as" from "everything it authenticates with". +async fn client_with_sender_identity() -> McpClient { + let account = AccountConfig::new("imap.invalid", "login-name") + .with_email("Mark@Example.com") + .with_aliases(["sales@example.com"]) + .with_display_name("Mark Blake"); + McpClient::start_with(Config::from_accounts(vec![("work".to_string(), account)])).await +} + +/// `list_accounts` answers "what can I send as": the display name and the +/// addresses, primary first — and still never a login or a host. +#[tokio::test] +async fn list_accounts_reports_the_addresses_an_account_sends_as() { + let mut client = client_with_sender_identity().await; + let resp = client + .request( + "tools/call", + json!({"name": "list_accounts", "arguments": {}}), + ) + .await; + let account = &resp["result"]["structuredContent"]["accounts"][0]; + + assert_eq!(account["displayName"], json!("Mark Blake")); + assert_eq!( + account["addresses"], + json!(["mark@example.com", "sales@example.com"]) + ); + let serialized = resp["result"].to_string(); + assert!( + !serialized.contains("login-name") && !serialized.contains("imap.invalid"), + "no login or host in the result: {serialized}" + ); + + let resp = client.request("tools/list", json!({})).await; + let tools = resp["result"]["tools"].as_array().expect("tools array"); + let output = &find_tool(tools, "list_accounts")["outputSchema"]; + let account = object_schema(&output["properties"]["accounts"]["items"]) + .expect("account item object schema"); + let required: Vec<&str> = account["required"] + .as_array() + .map(|values| values.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + assert!( + required.contains(&"addresses"), + "addresses is always present: {account:#}" + ); +} + +/// Both draft tools take a `from` and report the sender they resolved; the +/// address is checked against the account before any IMAP work. +#[tokio::test] +async fn draft_tools_take_a_sender_and_refuse_one_the_account_does_not_own() { + let mut client = client_with_sender_identity().await; + let resp = client.request("tools/list", json!({})).await; + let tools = resp["result"]["tools"].as_array().expect("tools array"); + for name in ["create_draft", "update_draft"] { + let tool = find_tool(tools, name); + assert!( + tool["inputSchema"]["properties"]["from"]["description"] + .as_str() + .is_some_and(|text| text.contains("list_accounts")), + "`{name}` takes a documented `from`: {tool:#}" + ); + let required: Vec<&str> = tool["outputSchema"]["required"] + .as_array() + .map(|values| values.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + assert!( + required.contains(&"from"), + "`{name}` always reports its sender" + ); + } + + for (name, arguments) in [ + ( + "create_draft", + json!({"account": "work", "to": ["you@example.net"], "from": "Mark "}), + ), + ( + "update_draft", + json!({ + "account": "work", + "mailbox": "Drafts", + "uid": 7, + "expectedUidValidity": 9, + "to": ["you@example.net"], + "from": "mark@elsewhere.example" + }), + ), + ] { + let resp = client + .request("tools/call", json!({"name": name, "arguments": arguments})) + .await; + assert_eq!(resp["result"]["isError"], json!(true), "{resp:#}"); + let message = resp["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default(); + assert!( + message.contains("mark@example.com, sales@example.com") && message.contains("alias"), + "`{name}` names the usable addresses: {message}" + ); + } +} + +/// Adding `from` did not loosen the schema: a near-miss name is still refused +/// with the field named, rather than silently ignored. +#[tokio::test] +async fn draft_tools_still_refuse_unknown_parameters() { + let mut client = McpClient::start().await; + let resp = client + .request( + "tools/call", + json!({ + "name": "create_draft", + "arguments": {"account": "dummy", "to": ["a@example.com"], "sender": "x@example.com"} + }), + ) + .await; + assert_eq!(resp["result"]["isError"], json!(true), "{resp:#}"); + assert!( + resp["result"]["content"][0]["text"] + .as_str() + .is_some_and(|message| message.contains("sender")), + "the error names the unknown field: {resp:#}" + ); +} + +#[tokio::test] +async fn list_identities_is_a_bounded_read_only_tool() { + let mut client = McpClient::start().await; + let resp = client.request("tools/list", json!({})).await; + let tools = resp["result"]["tools"].as_array().expect("tools array"); + let tool = find_tool(tools, "list_identities"); + + assert_eq!(tool["annotations"]["readOnlyHint"], json!(true)); + assert_eq!(tool["annotations"]["idempotentHint"], json!(true)); + assert!( + tool.get("execution") + .is_none_or(|execution| execution["taskSupport"].is_null()), + "a bounded read is not a task tool: {tool:#}" + ); + let sent = &tool["inputSchema"]["properties"]["sentMessages"]; + assert_eq!(schema_minimum(sent), Some(1.0)); + assert_eq!(schema_maximum(sent), Some(1000.0)); + let output = &tool["outputSchema"]["properties"]; + for field in ["sentMailbox", "scannedMessages", "identities"] { + assert!(output.get(field).is_some(), "output carries `{field}`"); + } + + let resp = client + .request( + "tools/call", + json!({"name": "list_identities", "arguments": {"account": "dummy", "sentMessages": 0}}), + ) + .await; + assert_eq!( + resp["error"]["code"].as_i64(), + Some(-32602), + "an out-of-range window fails before any IMAP work: {resp:#}" + ); +} + #[tokio::test] async fn task_result_is_repeatable_related_and_terminal_cancel_is_rejected() { let mut client = McpClient::start().await; diff --git a/vendor/imap-proto/.cargo_vcs_info.json b/vendor/imap-proto/.cargo_vcs_info.json index c280936..c20b2d1 100644 --- a/vendor/imap-proto/.cargo_vcs_info.json +++ b/vendor/imap-proto/.cargo_vcs_info.json @@ -1,6 +1,6 @@ { "git": { - "sha1": "6593414bd66f93b3caa5b5f7f24241e53c3d7da4" + "sha1": "31c7f80e57cac52cb4514c35a567f29250d5941e" }, - "path_in_vcs": "imap-proto" + "path_in_vcs": "" } \ No newline at end of file diff --git a/vendor/imap-proto/Cargo.lock b/vendor/imap-proto/Cargo.lock index 045a81b..f8d338d 100644 --- a/vendor/imap-proto/Cargo.lock +++ b/vendor/imap-proto/Cargo.lock @@ -10,7 +10,7 @@ checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" [[package]] name = "imap-proto" -version = "0.16.7" +version = "0.17.0" dependencies = [ "assert_matches", "nom", @@ -18,22 +18,15 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "nom" -version = "7.1.3" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" dependencies = [ "memchr", - "minimal-lexical", ] diff --git a/vendor/imap-proto/Cargo.toml b/vendor/imap-proto/Cargo.toml index 577bf57..f45fa99 100644 --- a/vendor/imap-proto/Cargo.toml +++ b/vendor/imap-proto/Cargo.toml @@ -11,9 +11,9 @@ [package] edition = "2021" -rust-version = "1.61" +rust-version = "1.70" name = "imap-proto" -version = "0.16.7" +version = "0.17.0" build = false autolib = false autobins = false @@ -21,7 +21,6 @@ autoexamples = false autotests = false autobenches = false description = "IMAP protocol parser and data structures" -homepage = "https://github.com/djc/tokio-imap" documentation = "https://docs.rs/imap-proto" readme = "README.md" keywords = [ @@ -34,10 +33,7 @@ categories = [ "parser-implementations", ] license = "MIT OR Apache-2.0" -repository = "https://github.com/djc/tokio-imap" - -[badges.maintenance] -status = "passively-maintained" +repository = "https://github.com/djc/imap-proto" [lib] name = "imap_proto" @@ -48,7 +44,7 @@ name = "parse_response" path = "examples/parse_response.rs" [dependencies.nom] -version = "7" +version = "8" [dev-dependencies.assert_matches] version = "1.3" diff --git a/vendor/imap-proto/LICENSE-APACHE b/vendor/imap-proto/LICENSE-APACHE new file mode 100644 index 0000000..16fe87b --- /dev/null +++ b/vendor/imap-proto/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/vendor/imap-proto/LICENSE-MIT b/vendor/imap-proto/LICENSE-MIT new file mode 100644 index 0000000..1def200 --- /dev/null +++ b/vendor/imap-proto/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright (c) 2017 Dirkjan Ochtman + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/vendor/imap-proto/README.md b/vendor/imap-proto/README.md index 1cb1688..9cb1a6b 100644 --- a/vendor/imap-proto/README.md +++ b/vendor/imap-proto/README.md @@ -1,73 +1,27 @@ -# imap-proto and tokio-imap +# imap-proto [![Build status](https://github.com/djc/tokio-imap/workflows/CI/badge.svg)](https://github.com/djc/tokio-imap/actions?query=workflow%3ACI) -[![Join the chat at https://gitter.im/djc/tokio-imap](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/djc/tokio-imap?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +[![crates.io, downloads](https://img.shields.io/crates/d/imap-proto.svg)](https://crates.io/crates/imap-proto) +[![crates.io, latest release](https://img.shields.io/crates/v/imap-proto.svg)](https://crates.io/crates/imap-proto) +[![API docs, latest release](https://docs.rs/imap-proto/badge.svg)](http://docs.rs/imap-proto) +[![Chat](https://img.shields.io/discord/976380008299917365?logo=discord)](https://discord.gg/ujJ4pGzkGU) [![MIT license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE-MIT) [![Apache License 2.0](https://img.shields.io/badge/license-ALv2-blue.svg)](./LICENSE-APACHE) All feedback welcome. Feel free to file bugs, requests for documentation and -any other feedback to the [issue tracker][issues] or [tweet me][twitter]. +any other feedback to the [issue tracker][issues]. -tokio-imap and imap-proto are maintained by Dirkjan Ochtman. If you depend on these +imap-proto was created and is maintained by Dirkjan Ochtman. If you depend on this projects, please support the project via [GitHub Sponsors] or contact me for support. [issues]: https://github.com/djc/tokio-imap/issues -[twitter]: https://twitter.com/djco/ [GitHub Sponsors]: https://github.com/sponsors/djc -## tokio-imap: futures-based IMAP client - -[![crates.io, downloads](https://img.shields.io/crates/d/tokio-imap.svg)](https://crates.io/crates/tokio-imap) -[![crates.io, latest release](https://img.shields.io/crates/v/tokio-imap.svg)](https://crates.io/crates/tokio-imap) -[![API docs, latest release](https://docs.rs/tokio-imap/badge.svg)](http://docs.rs/tokio-imap) - -NOTE: Unlike imap-proto, tokio-imap doesn't receive much maintenance. As -an alternative we suggest to evaluate async-imap instead. - -A [Tokio stack-based][Tokio_stack], fully asynchronous IMAP library, with strong focus on following -the relevant specs, mainly [IMAP4rev1][rfc3501], but with limited support for -the [Conditional STORE][rfc4551] extension. The type system is used to help -enforce correctness where possible. So far, there is only client code and lots -of infrastructure that supposedly could be shared -- no server yet. (If you -want a tokio-based server, look at [IMAPServer][IMAPServer].) - -### Feature highlights - -* Fully asynchronous by using [tokio-core][tokio-core] and [tokio-io][tokio-io] -* Uses the type system to help enforce correct operation according to spec -* [nom][nom]-based parser (in imap-proto), so far only used for server response messages - -### Limitations - -* Alpha-level implementation -- no tests yet, limited protocol coverage -* Server is totally unimplemented at this stage - -[rfc3501]: https://tools.ietf.org/html/rfc3501 -[rfc4551]: https://tools.ietf.org/html/rfc4551 -[IMAPServer]: https://github.com/Nordgedanken/IMAPServer-rs -[docs]: https://docs.rs/tokio-imap -[tokio-core]: https://github.com/tokio-rs/tokio-core -[tokio-io]: https://github.com/tokio-rs/tokio-io -[Tokio_stack]: https://tokio.rs -[nom]: https://github.com/Geal/nom - -### How to get started - -Have a look at the [mailsync][mailsync] crate for example usage. - -[mailsync]: https://github.com/djc/mailsync - -## imap-proto: IMAP types and protocol parser - -[![crates.io, downloads](https://img.shields.io/crates/d/imap-proto.svg)](https://crates.io/crates/imap-proto) -[![crates.io, latest release](https://img.shields.io/crates/v/imap-proto.svg)](https://crates.io/crates/imap-proto) -[![API docs, latest release](https://docs.rs/imap-proto/badge.svg)](http://docs.rs/imap-proto) +## Features imap-proto is a low-level IMAP protocol support crate, using the type system to -provide a safe API. It was extracted from tokio-imap into a separate crate so that -different protocol implementations can share it as common infrastructure -(as proposed by [rust-imap][rust-imap] contributors). -The code tries to closely follow the [IMAP4rev1 RFC][rfc3501], plus extensions. +provide a safe API. The code tries to closely follow the [IMAP4rev1 RFC][rfc3501], plus +several extensions. Protocol support is implemented in three parts: @@ -76,18 +30,3 @@ Protocol support is implemented in three parts: * Builder types to help produce protocol messages [rfc3501]: https://tools.ietf.org/html/rfc3501 -[tokio-imap]: https://github.com/djc/tokio-imap -[rust-imap]: https://github.com/mattnenterprise/rust-imap -[issues]: https://github.com/djc/imap-proto/issues -[twitter]: https://twitter.com/djco/ - -### Progress - -- [ ] Client - - [ ] Parser: many common server responses implemented - - [ ] Types: most common types implemented - - [ ] Message builder: most common commands implemented -- [ ] Server - - [ ] Parser: not started - - [ ] Types: not started - - [ ] Message builder: not started diff --git a/vendor/imap-proto/examples/parse_response.rs b/vendor/imap-proto/examples/parse_response.rs index 1175fa3..25dd3f8 100644 --- a/vendor/imap-proto/examples/parse_response.rs +++ b/vendor/imap-proto/examples/parse_response.rs @@ -12,7 +12,7 @@ fn main() -> std::io::Result<()> { line }; - match Response::from_bytes(line.replace('\n', "\r\n").as_bytes()) { + match Response::parse(line.replace('\n', "\r\n").as_bytes()) { Ok((remaining, command)) => { println!("{command:#?}"); diff --git a/vendor/imap-proto/src/parser/bodystructure.rs b/vendor/imap-proto/src/bodystructure.rs similarity index 92% rename from vendor/imap-proto/src/parser/bodystructure.rs rename to vendor/imap-proto/src/bodystructure.rs index eef5b07..6ee4ce4 100644 --- a/vendor/imap-proto/src/parser/bodystructure.rs +++ b/vendor/imap-proto/src/bodystructure.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; -use crate::types::BodyStructure; +use crate::rfc3501::body_structure::BodyStructure; + /// An utility parser helping to find the appropriate /// section part from a FETCH response. pub struct BodyStructParser<'a> { @@ -33,10 +34,7 @@ impl<'a> BodyStructParser<'a> { /// # Arguments /// /// * `func` - The filter used to search elements within the bodystructure. - pub fn search(&self, func: F) -> Option> - where - F: Fn(&'a BodyStructure<'a>) -> bool, - { + pub fn search(&self, func: impl Fn(&'a BodyStructure<'a>) -> bool) -> Option> { let elem: Vec<_> = self .map .iter() diff --git a/vendor/imap-proto/src/builders/command.rs b/vendor/imap-proto/src/builders/command.rs index fb0867f..cdb6eae 100644 --- a/vendor/imap-proto/src/builders/command.rs +++ b/vendor/imap-proto/src/builders/command.rs @@ -3,8 +3,6 @@ use std::marker::PhantomData; use std::ops::{RangeFrom, RangeInclusive}; use std::str; -use crate::types::{AttrMacro, Attribute, State}; - pub struct CommandBuilder {} impl CommandBuilder { @@ -121,6 +119,14 @@ impl From> for Command { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum State { + NotAuthenticated, + Authenticated, + Selected, + Logout, +} + pub mod select { pub struct NoParams; pub struct Params; @@ -261,6 +267,24 @@ fn push_attr(cmd: &mut Vec, attr: Attribute) { ); } +#[derive(Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Attribute { + Body, + Envelope, + Flags, + InternalDate, + ModSeq, // RFC 4551, section 3.3.2 + Rfc822, + Rfc822Size, + Rfc822Text, + Uid, + /// + GmailLabels, + GmailMsgId, + GmailThrId, +} + impl From> for Command { fn from(mut cmd: FetchCommand) -> Command { cmd.args.push(b')'); @@ -336,6 +360,13 @@ fn quoted_string(s: &str) -> Result, &'static str> { } } +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum AttrMacro { + All, + Fast, + Full, +} + #[cfg(test)] mod tests { use super::{quoted_string, Attribute, Command, CommandBuilder}; diff --git a/vendor/imap-proto/src/parser/core.rs b/vendor/imap-proto/src/core.rs similarity index 69% rename from vendor/imap-proto/src/parser/core.rs rename to vendor/imap-proto/src/core.rs index f7d3658..d67cff4 100644 --- a/vendor/imap-proto/src/parser/core.rs +++ b/vendor/imap-proto/src/core.rs @@ -2,14 +2,19 @@ use nom::{ branch::alt, bytes::streaming::{escaped, tag, tag_no_case, take, take_while, take_while1}, character::streaming::{char, digit1, one_of, space0}, - combinator::{map, map_res, opt}, + combinator::{map, map_res, opt, peek}, multi::{separated_list0, separated_list1}, - sequence::{delimited, preceded, tuple}, - IResult, + sequence::{delimited, preceded}, + IResult, Parser, }; +use std::borrow::Cow; use std::str::{from_utf8, FromStr}; +pub(crate) fn to_owned_cow(c: Cow<'_, T>) -> Cow<'static, T> { + Cow::Owned(c.into_owned()) +} + // ----- number ----- // number = 1*DIGIT @@ -43,7 +48,7 @@ pub fn number_64(i: &[u8]) -> IResult<&[u8], u64> { // ; these two regardless of order. // ; seq-number is a nz-number pub fn sequence_range(i: &[u8]) -> IResult<&[u8], std::ops::RangeInclusive> { - map(tuple((number, tag(":"), number)), |(s, _, e)| s..=e)(i) + map((number, tag(":"), number), |(s, _, e)| s..=e).parse(i) } // sequence-set = (seq-number / seq-range) *("," sequence-set) @@ -51,37 +56,50 @@ pub fn sequence_range(i: &[u8]) -> IResult<&[u8], std::ops::RangeInclusive> // ; Servers MAY coalesce overlaps and/or execute the // ; sequence in any order. pub fn sequence_set(i: &[u8]) -> IResult<&[u8], Vec>> { - separated_list1(tag(","), alt((sequence_range, map(number, |n| n..=n))))(i) + separated_list1(tag(","), alt((sequence_range, map(number, |n| n..=n)))).parse(i) } // ----- string ----- // string = quoted / literal pub fn string(i: &[u8]) -> IResult<&[u8], &[u8]> { - alt((quoted, literal))(i) + alt((quoted, literal)).parse(i) +} + +#[inline] +fn lossy_str(bytes: &[u8]) -> Cow<'_, str> { + String::from_utf8_lossy(bytes) } -// string bytes as utf8 -pub fn string_utf8(i: &[u8]) -> IResult<&[u8], &str> { - map_res(string, from_utf8)(i) +// string bytes as utf8 — falls back to lossy decoding when the literal +// or quoted contents include non-UTF-8 bytes (e.g. Latin-1 / ISO-8859-9 +// MIME filenames in BODYSTRUCTURE responses). Strict from_utf8 would +// fail, breaking the entire response stream on a single bad mail. +pub fn string_utf8(i: &[u8]) -> IResult<&[u8], Cow<'_, str>> { + map(string, lossy_str).parse(i) } // quoted = DQUOTE *QUOTED-CHAR DQUOTE pub fn quoted(i: &[u8]) -> IResult<&[u8], &[u8]> { delimited( char('"'), - escaped( - take_while1(|byte| is_text_char(byte) && !is_quoted_specials(byte)), - '\\', - one_of("\\\""), - ), + alt(( + // nom 8's `escaped` rejects empty input, so match an empty quoted string separately. + map(peek(char('"')), |_| &b""[..]), + escaped( + take_while1(|byte| is_text_char(byte) && !is_quoted_specials(byte)), + '\\', + one_of("\\\""), + ), + )), char('"'), - )(i) + ) + .parse(i) } -// quoted bytes as utf8 -pub fn quoted_utf8(i: &[u8]) -> IResult<&[u8], &str> { - map_res(quoted, from_utf8)(i) +// quoted bytes as utf8 — lossy, see comment on string_utf8. +pub fn quoted_utf8(i: &[u8]) -> IResult<&[u8], Cow<'_, str>> { + map(quoted, lossy_str).parse(i) } // quoted-specials = DQUOTE / "\" @@ -92,9 +110,9 @@ pub fn is_quoted_specials(c: u8) -> bool { /// literal = "{" number "}" CRLF *CHAR8 /// ; Number represents the number of CHAR8s pub fn literal(input: &[u8]) -> IResult<&[u8], &[u8]> { - let mut parser = tuple((tag(b"{"), number, tag(b"}"), tag("\r\n"))); + let mut parser = (tag("{"), number, tag("}"), tag("\r\n")); - let (remaining, (_, count, _, _)) = parser(input)?; + let (remaining, (_, count, _, _)) = parser.parse(input)?; let (remaining, data) = take(count)(remaining)?; @@ -105,12 +123,12 @@ pub fn literal(input: &[u8]) -> IResult<&[u8], &[u8]> { // astring = 1*ASTRING-CHAR / string pub fn astring(i: &[u8]) -> IResult<&[u8], &[u8]> { - alt((take_while1(is_astring_char), string))(i) + alt((take_while1(is_astring_char), string)).parse(i) } -// astring bytes as utf8 -pub fn astring_utf8(i: &[u8]) -> IResult<&[u8], &str> { - map_res(astring, from_utf8)(i) +// astring bytes as utf8 — lossy, see comment on string_utf8. +pub fn astring_utf8(i: &[u8]) -> IResult<&[u8], Cow<'_, str>> { + map(astring, lossy_str).parse(i) } // ASTRING-CHAR = ATOM-CHAR / resp-specials @@ -142,19 +160,19 @@ pub fn is_resp_specials(c: u8) -> bool { // atom = 1*ATOM-CHAR pub fn atom(i: &[u8]) -> IResult<&[u8], &str> { - map_res(take_while1(is_atom_char), from_utf8)(i) + map_res(take_while1(is_atom_char), from_utf8).parse(i) } // ----- nstring ----- nil or string // nstring = string / nil pub fn nstring(i: &[u8]) -> IResult<&[u8], Option<&[u8]>> { - alt((map(nil, |_| None), map(string, Some)))(i) + alt((map(nil, |_| None), map(string, Some))).parse(i) } -// nstring bytes as utf8 -pub fn nstring_utf8(i: &[u8]) -> IResult<&[u8], Option<&str>> { - alt((map(nil, |_| None), map(string_utf8, Some)))(i) +// nstring bytes as utf8 — lossy, see comment on string_utf8. +pub fn nstring_utf8(i: &[u8]) -> IResult<&[u8], Option>> { + alt((map(nil, |_| None), map(string_utf8, Some))).parse(i) } // nil = "NIL" @@ -164,9 +182,9 @@ pub fn nil(i: &[u8]) -> IResult<&[u8], &[u8]> { // ----- text ----- -// text = 1*TEXT-CHAR -pub fn text(i: &[u8]) -> IResult<&[u8], &str> { - map_res(take_while(is_text_char), from_utf8)(i) +// text = 1*TEXT-CHAR — lossy, see comment on string_utf8. +pub fn text(i: &[u8]) -> IResult<&[u8], Cow<'_, str>> { + map(take_while(is_text_char), lossy_str).parse(i) } // TEXT-CHAR = @@ -178,8 +196,17 @@ pub fn is_text_char(c: u8) -> bool { // ; any 7-bit US-ASCII character, // ; excluding NUL // From RFC5234 +// +// Real-world IMAP servers (Dovecot, Cyrus, …) regularly emit 8-bit bytes +// inside quoted strings and literals — typically MIME filenames in +// non-UTF-8 encodings like Latin-1 or ISO-8859-9 (Turkish). RFC 6855 / +// IMAP4rev2 (RFC 9051) formally allow UTF-8 in these positions. Strict +// 7-bit rejection breaks BODYSTRUCTURE parsing on a single bad mail and +// desyncs the entire stream. Accept any non-NUL byte and let the +// higher-level UTF-8 conversion deal with non-UTF-8 sequences via lossy +// decoding. pub fn is_char(c: u8) -> bool { - matches!(c, 0x01..=0x7F) + c != 0 } // ----- others ----- @@ -189,19 +216,19 @@ pub fn is_list_wildcards(c: u8) -> bool { c == b'%' || c == b'*' } -pub fn paren_delimited<'a, F, O, E>(f: F) -> impl FnMut(&'a [u8]) -> IResult<&'a [u8], O, E> +pub fn paren_delimited<'a, O, E>( + f: impl Parser<&'a [u8], Output = O, Error = E>, +) -> impl Parser<&'a [u8], Output = O, Error = E> where - F: FnMut(&'a [u8]) -> IResult<&'a [u8], O, E>, E: nom::error::ParseError<&'a [u8]>, { delimited(char('('), f, char(')')) } -pub fn parenthesized_nonempty_list<'a, F, O, E>( - f: F, -) -> impl FnMut(&'a [u8]) -> IResult<&'a [u8], Vec, E> +pub fn parenthesized_nonempty_list<'a, O, E>( + f: impl Parser<&'a [u8], Output = O, Error = E>, +) -> impl Parser<&'a [u8], Output = Vec, Error = E> where - F: FnMut(&'a [u8]) -> IResult<&'a [u8], O, E>, E: nom::error::ParseError<&'a [u8]>, { delimited( @@ -209,13 +236,14 @@ where separated_list1(char(' '), f), // Targeted lenience: Some real-world IMAP servers // insert extra whitespace before the closing parenthesis. - tuple((space0, char(')'))), + (space0, char(')')), ) } -pub fn parenthesized_list<'a, F, O, E>(f: F) -> impl FnMut(&'a [u8]) -> IResult<&'a [u8], Vec, E> +pub fn parenthesized_list<'a, O, E>( + f: impl Parser<&'a [u8], Output = O, Error = E>, +) -> impl Parser<&'a [u8], Output = Vec, Error = E> where - F: FnMut(&'a [u8]) -> IResult<&'a [u8], O, E>, E: nom::error::ParseError<&'a [u8]>, { delimited( @@ -228,11 +256,13 @@ where ) } -pub fn opt_opt<'a, F, O, E>(mut f: F) -> impl FnMut(&'a [u8]) -> IResult<&'a [u8], Option, E> +pub fn opt_opt<'a, O, E>( + mut f: impl Parser<&'a [u8], Output = Option, Error = E>, +) -> impl Parser<&'a [u8], Output = Option, Error = E> where - F: FnMut(&'a [u8]) -> IResult<&'a [u8], Option, E>, + E: nom::error::ParseError<&'a [u8]>, { - move |i: &[u8]| match f(i) { + move |i: &'a [u8]| match f.parse(i) { Ok((i, o)) => Ok((i, o)), Err(nom::Err::Error(_)) => Ok((i, None)), Err(e) => Err(e), diff --git a/vendor/imap-proto/src/parser/gmail.rs b/vendor/imap-proto/src/gmail.rs similarity index 81% rename from vendor/imap-proto/src/parser/gmail.rs rename to vendor/imap-proto/src/gmail.rs index f4049c5..e48372a 100644 --- a/vendor/imap-proto/src/parser/gmail.rs +++ b/vendor/imap-proto/src/gmail.rs @@ -4,9 +4,9 @@ use nom::branch::alt; use nom::bytes::streaming::tag_no_case; use nom::combinator::map; use nom::sequence::preceded; -use nom::IResult; +use nom::{IResult, Parser}; -use crate::{AttributeValue, MailboxDatum}; +use crate::rfc3501::{AttributeValue, MailboxDatum}; use super::core::{number_64, parenthesized_list, quoted_utf8}; use super::rfc3501::flag; @@ -14,45 +14,46 @@ use super::rfc3501::flag; pub(crate) fn gmail_label_list(i: &[u8]) -> IResult<&[u8], Vec>> { preceded( tag_no_case("X-GM-LABELS "), - parenthesized_list(map(alt((flag, quoted_utf8)), Cow::Borrowed)), - )(i) + parenthesized_list(alt((map(flag, Cow::Borrowed), quoted_utf8))), + ) + .parse(i) } pub(crate) fn msg_att_gmail_labels(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { - map(gmail_label_list, AttributeValue::GmailLabels)(i) + map(gmail_label_list, AttributeValue::GmailLabels).parse(i) } pub(crate) fn mailbox_data_gmail_labels(i: &[u8]) -> IResult<&[u8], MailboxDatum<'_>> { - map(gmail_label_list, MailboxDatum::GmailLabels)(i) + map(gmail_label_list, MailboxDatum::GmailLabels).parse(i) } pub(crate) fn gmail_msgid(i: &[u8]) -> IResult<&[u8], u64> { - preceded(tag_no_case("X-GM-MSGID "), number_64)(i) + preceded(tag_no_case("X-GM-MSGID "), number_64).parse(i) } pub(crate) fn msg_att_gmail_msgid(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { - map(gmail_msgid, AttributeValue::GmailMsgId)(i) + map(gmail_msgid, AttributeValue::GmailMsgId).parse(i) } pub(crate) fn mailbox_data_gmail_msgid(i: &[u8]) -> IResult<&[u8], MailboxDatum<'_>> { - map(gmail_msgid, MailboxDatum::GmailMsgId)(i) + map(gmail_msgid, MailboxDatum::GmailMsgId).parse(i) } pub(crate) fn gmail_thrid(i: &[u8]) -> IResult<&[u8], u64> { - preceded(tag_no_case("X-GM-THRID "), number_64)(i) + preceded(tag_no_case("X-GM-THRID "), number_64).parse(i) } pub(crate) fn msg_att_gmail_thrid(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { - map(gmail_thrid, AttributeValue::GmailThrId)(i) + map(gmail_thrid, AttributeValue::GmailThrId).parse(i) } pub(crate) fn mailbox_data_gmail_thrid(i: &[u8]) -> IResult<&[u8], MailboxDatum<'_>> { - map(gmail_thrid, MailboxDatum::GmailThrId)(i) + map(gmail_thrid, MailboxDatum::GmailThrId).parse(i) } #[cfg(test)] mod tests { - use crate::types::*; + use super::AttributeValue; #[test] fn test_gmail_labels() { let env = br#"X-GM-LABELS (\Inbox \Sent Important "Muy Importante") "#; diff --git a/vendor/imap-proto/src/lib.rs b/vendor/imap-proto/src/lib.rs index 10f20f2..57feb2f 100644 --- a/vendor/imap-proto/src/lib.rs +++ b/vendor/imap-proto/src/lib.rs @@ -1,6 +1,120 @@ +use std::borrow::Cow; +use std::collections::HashMap; + +use nom::{branch::alt, IResult, Parser}; + +pub mod core; +use crate::core::to_owned_cow; + +pub mod rfc3501; +pub use rfc3501::body::{MessageSection, SectionPath}; +pub use rfc3501::body_structure::{ + BodyContentCommon, BodyContentSinglePart, BodyExt1Part, BodyExtMPart, BodyExtension, + BodyFields, BodyParams, BodyStructure, ContentDisposition, ContentEncoding, ContentType, +}; +pub use rfc3501::{ + Address, AttributeValue, Capability, Envelope, MailboxDatum, MailboxListData, NameAttribute, + Outcome, RequestId, ResponseCode, Status, StatusAttribute, +}; + +pub mod bodystructure; pub mod builders; -pub mod parser; -pub mod types; +pub mod gmail; +pub mod rfc2087; +use rfc2087::{Quota, QuotaRoot}; +pub mod rfc2971; +pub mod rfc4314; +use rfc4314::{Acl, ListRights, MyRights}; +pub mod rfc4315; +pub mod rfc4551; +pub mod rfc5161; +pub mod rfc5256; +pub mod rfc5464; +pub mod rfc7162; + +#[cfg(test)] +mod tests; + +#[derive(Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Response<'a> { + Capabilities(Vec>), + Continue(Outcome<'a>), + Done { + tag: RequestId, + status: Status, + outcome: Outcome<'a>, + }, + Data { + status: Status, + outcome: Outcome<'a>, + }, + Expunge(u32), + Vanished { + earlier: bool, + uids: Vec>, + }, + Fetch(u32, Vec>), + MailboxData(MailboxDatum<'a>), + Quota(Quota<'a>), + QuotaRoot(QuotaRoot<'a>), + Id(Option, Cow<'a, str>>>), + Acl(Acl<'a>), + ListRights(ListRights<'a>), + MyRights(MyRights<'a>), +} + +impl<'a> Response<'a> { + pub fn parse(msg: &'a [u8]) -> ParseResult<'a> { + alt(( + rfc3501::continue_req, + rfc3501::response_data, + rfc3501::response_tagged, + )) + .parse(msg) + } + + pub fn into_owned(self) -> Response<'static> { + match self { + Response::Capabilities(capabilities) => Response::Capabilities( + capabilities + .into_iter() + .map(Capability::into_owned) + .collect(), + ), + Response::Continue(outcome) => Response::Continue(outcome.into_owned()), + Response::Done { + tag, + status, + outcome, + } => Response::Done { + tag, + status, + outcome: outcome.into_owned(), + }, + Response::Data { status, outcome } => Response::Data { + status, + outcome: outcome.into_owned(), + }, + Response::Expunge(seq) => Response::Expunge(seq), + Response::Vanished { earlier, uids } => Response::Vanished { earlier, uids }, + Response::Fetch(seq, attrs) => Response::Fetch( + seq, + attrs.into_iter().map(AttributeValue::into_owned).collect(), + ), + Response::MailboxData(datum) => Response::MailboxData(datum.into_owned()), + Response::Quota(quota) => Response::Quota(quota.into_owned()), + Response::QuotaRoot(quota_root) => Response::QuotaRoot(quota_root.into_owned()), + Response::Id(map) => Response::Id(map.map(|m| { + m.into_iter() + .map(|(k, v)| (to_owned_cow(k), to_owned_cow(v))) + .collect() + })), + Response::Acl(acl_list) => Response::Acl(acl_list.into_owned()), + Response::ListRights(rights) => Response::ListRights(rights.into_owned()), + Response::MyRights(rights) => Response::MyRights(rights.into_owned()), + } + } +} -pub use parser::ParseResult; -pub use types::*; +pub type ParseResult<'a> = IResult<&'a [u8], Response<'a>>; diff --git a/vendor/imap-proto/src/parser/mod.rs b/vendor/imap-proto/src/parser/mod.rs deleted file mode 100644 index 4ac7578..0000000 --- a/vendor/imap-proto/src/parser/mod.rs +++ /dev/null @@ -1,30 +0,0 @@ -use crate::types::Response; -use nom::{branch::alt, IResult}; - -pub mod core; - -pub mod bodystructure; -pub mod gmail; -pub mod rfc2087; -pub mod rfc2971; -pub mod rfc3501; -pub mod rfc4314; -pub mod rfc4315; -pub mod rfc4551; -pub mod rfc5161; -pub mod rfc5256; -pub mod rfc5464; -pub mod rfc7162; - -#[cfg(test)] -mod tests; - -pub fn parse_response(msg: &[u8]) -> ParseResult<'_> { - alt(( - rfc3501::continue_req, - rfc3501::response_data, - rfc3501::response_tagged, - ))(msg) -} - -pub type ParseResult<'a> = IResult<&'a [u8], Response<'a>>; diff --git a/vendor/imap-proto/src/parser/rfc3501/body_structure.rs b/vendor/imap-proto/src/parser/rfc3501/body_structure.rs deleted file mode 100644 index b2bfda6..0000000 --- a/vendor/imap-proto/src/parser/rfc3501/body_structure.rs +++ /dev/null @@ -1,535 +0,0 @@ -use nom::{ - branch::alt, - bytes::streaming::{tag, tag_no_case}, - character::streaming::char, - combinator::{map, opt}, - multi::many1, - sequence::{delimited, preceded, tuple}, - IResult, -}; -use std::borrow::Cow; - -use crate::{ - parser::{core::*, rfc3501::envelope}, - types::*, -}; - -// body-fields = body-fld-param SP body-fld-id SP body-fld-desc SP -// body-fld-enc SP body-fld-octets -fn body_fields(i: &[u8]) -> IResult<&[u8], BodyFields<'_>> { - let (i, (param, _, id, _, description, _, transfer_encoding, _, octets)) = tuple(( - body_param, - tag(" "), - // body id seems to refer to the Message-ID or possibly Content-ID header, which - // by the definition in RFC 2822 seems to resolve to all ASCII characters (through - // a large amount of indirection which I did not have the patience to fully explore) - nstring_utf8, - tag(" "), - // Per https://tools.ietf.org/html/rfc2045#section-8, description should be all ASCII - nstring_utf8, - tag(" "), - body_encoding, - tag(" "), - number, - ))(i)?; - Ok(( - i, - BodyFields { - param, - id: id.map(Cow::Borrowed), - description: description.map(Cow::Borrowed), - transfer_encoding, - octets, - }, - )) -} - -// body-ext-1part = body-fld-md5 [SP body-fld-dsp [SP body-fld-lang -// [SP body-fld-loc *(SP body-extension)]]] -// ; MUST NOT be returned on non-extensible -// ; "BODY" fetch -fn body_ext_1part(i: &[u8]) -> IResult<&[u8], BodyExt1Part<'_>> { - let (i, (md5, disposition, language, location, extension)) = tuple(( - // Per RFC 1864, MD5 values are base64-encoded - opt_opt(preceded(tag(" "), nstring_utf8)), - opt_opt(preceded(tag(" "), body_disposition)), - opt_opt(preceded(tag(" "), body_lang)), - // Location appears to reference a URL, which by RFC 1738 (section 2.2) should be ASCII - opt_opt(preceded(tag(" "), nstring_utf8)), - opt(preceded(tag(" "), body_extension)), - ))(i)?; - Ok(( - i, - BodyExt1Part { - md5: md5.map(Cow::Borrowed), - disposition, - language, - location: location.map(Cow::Borrowed), - extension, - }, - )) -} - -// body-ext-mpart = body-fld-param [SP body-fld-dsp [SP body-fld-lang -// [SP body-fld-loc *(SP body-extension)]]] -// ; MUST NOT be returned on non-extensible -// ; "BODY" fetch -fn body_ext_mpart(i: &[u8]) -> IResult<&[u8], BodyExtMPart<'_>> { - let (i, (param, disposition, language, location, extension)) = tuple(( - opt_opt(preceded(tag(" "), body_param)), - opt_opt(preceded(tag(" "), body_disposition)), - opt_opt(preceded(tag(" "), body_lang)), - // Location appears to reference a URL, which by RFC 1738 (section 2.2) should be ASCII - opt_opt(preceded(tag(" "), nstring_utf8)), - opt(preceded(tag(" "), body_extension)), - ))(i)?; - Ok(( - i, - BodyExtMPart { - param, - disposition, - language, - location: location.map(Cow::Borrowed), - extension, - }, - )) -} - -fn body_encoding(i: &[u8]) -> IResult<&[u8], ContentEncoding<'_>> { - alt(( - delimited( - char('"'), - alt(( - map(tag_no_case("7BIT"), |_| ContentEncoding::SevenBit), - map(tag_no_case("8BIT"), |_| ContentEncoding::EightBit), - map(tag_no_case("BINARY"), |_| ContentEncoding::Binary), - map(tag_no_case("BASE64"), |_| ContentEncoding::Base64), - map(tag_no_case("QUOTED-PRINTABLE"), |_| { - ContentEncoding::QuotedPrintable - }), - )), - char('"'), - ), - map(string_utf8, |enc| { - ContentEncoding::Other(Cow::Borrowed(enc)) - }), - ))(i) -} - -fn body_lang(i: &[u8]) -> IResult<&[u8], Option>>> { - alt(( - // body language seems to refer to RFC 3066 language tags, which should be ASCII-only - map(nstring_utf8, |v| v.map(|s| vec![Cow::Borrowed(s)])), - map( - parenthesized_nonempty_list(map(string_utf8, Cow::Borrowed)), - Option::from, - ), - ))(i) -} - -fn body_param(i: &[u8]) -> IResult<&[u8], BodyParams<'_>> { - alt(( - map(nil, |_| None), - map( - parenthesized_nonempty_list(map( - tuple((string_utf8, tag(" "), string_utf8)), - |(key, _, val)| (Cow::Borrowed(key), Cow::Borrowed(val)), - )), - Option::from, - ), - ))(i) -} - -fn body_extension(i: &[u8]) -> IResult<&[u8], BodyExtension<'_>> { - alt(( - map(number, BodyExtension::Num), - // Cannot find documentation on character encoding for body extension values. - // So far, assuming UTF-8 seems fine, please report if you run into issues here. - map(nstring_utf8, |v| BodyExtension::Str(v.map(Cow::Borrowed))), - map( - parenthesized_nonempty_list(body_extension), - BodyExtension::List, - ), - ))(i) -} - -fn body_disposition(i: &[u8]) -> IResult<&[u8], Option>> { - alt(( - map(nil, |_| None), - paren_delimited(map( - tuple((string_utf8, tag(" "), body_param)), - |(ty, _, params)| { - Some(ContentDisposition { - ty: Cow::Borrowed(ty), - params, - }) - }, - )), - ))(i) -} - -fn body_type_basic(i: &[u8]) -> IResult<&[u8], BodyStructure<'_>> { - map( - tuple(( - string_utf8, - tag(" "), - string_utf8, - tag(" "), - body_fields, - body_ext_1part, - )), - |(ty, _, subtype, _, fields, ext)| BodyStructure::Basic { - common: BodyContentCommon { - ty: ContentType { - ty: Cow::Borrowed(ty), - subtype: Cow::Borrowed(subtype), - params: fields.param, - }, - disposition: ext.disposition, - language: ext.language, - location: ext.location, - }, - other: BodyContentSinglePart { - id: fields.id, - md5: ext.md5, - octets: fields.octets, - description: fields.description, - transfer_encoding: fields.transfer_encoding, - }, - extension: ext.extension, - }, - )(i) -} - -fn body_type_text(i: &[u8]) -> IResult<&[u8], BodyStructure<'_>> { - map( - tuple(( - tag_no_case("\"TEXT\""), - tag(" "), - string_utf8, - tag(" "), - body_fields, - tag(" "), - number, - body_ext_1part, - )), - |(_, _, subtype, _, fields, _, lines, ext)| BodyStructure::Text { - common: BodyContentCommon { - ty: ContentType { - ty: Cow::Borrowed("TEXT"), - subtype: Cow::Borrowed(subtype), - params: fields.param, - }, - disposition: ext.disposition, - language: ext.language, - location: ext.location, - }, - other: BodyContentSinglePart { - id: fields.id, - md5: ext.md5, - octets: fields.octets, - description: fields.description, - transfer_encoding: fields.transfer_encoding, - }, - lines, - extension: ext.extension, - }, - )(i) -} - -fn body_type_message(i: &[u8]) -> IResult<&[u8], BodyStructure<'_>> { - map( - tuple(( - tag_no_case("\"MESSAGE\" \"RFC822\""), - tag(" "), - body_fields, - tag(" "), - envelope, - tag(" "), - body, - tag(" "), - number, - body_ext_1part, - )), - |(_, _, fields, _, envelope, _, body, _, lines, ext)| BodyStructure::Message { - common: BodyContentCommon { - ty: ContentType { - ty: Cow::Borrowed("MESSAGE"), - subtype: Cow::Borrowed("RFC822"), - params: fields.param, - }, - disposition: ext.disposition, - language: ext.language, - location: ext.location, - }, - other: BodyContentSinglePart { - id: fields.id, - md5: ext.md5, - octets: fields.octets, - description: fields.description, - transfer_encoding: fields.transfer_encoding, - }, - envelope, - body: Box::new(body), - lines, - extension: ext.extension, - }, - )(i) -} - -fn body_type_multipart(i: &[u8]) -> IResult<&[u8], BodyStructure<'_>> { - map( - tuple((many1(body), tag(" "), string_utf8, body_ext_mpart)), - |(bodies, _, subtype, ext)| BodyStructure::Multipart { - common: BodyContentCommon { - ty: ContentType { - ty: Cow::Borrowed("MULTIPART"), - subtype: Cow::Borrowed(subtype), - params: ext.param, - }, - disposition: ext.disposition, - language: ext.language, - location: ext.location, - }, - bodies, - extension: ext.extension, - }, - )(i) -} - -pub(crate) fn body(i: &[u8]) -> IResult<&[u8], BodyStructure<'_>> { - paren_delimited(alt(( - body_type_text, - body_type_message, - body_type_basic, - body_type_multipart, - )))(i) -} - -pub(crate) fn msg_att_body_structure(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { - map(tuple((tag_no_case("BODYSTRUCTURE "), body)), |(_, body)| { - AttributeValue::BodyStructure(body) - })(i) -} - -#[cfg(test)] -mod tests { - use super::*; - use assert_matches::assert_matches; - - const EMPTY: &[u8] = &[]; - - // body-fld-param SP body-fld-id SP body-fld-desc SP body-fld-enc SP body-fld-octets - const BODY_FIELDS: &str = r#"("foo" "bar") "id" "desc" "7BIT" 1337"#; - const BODY_FIELD_PARAM_PAIR: (Cow<'_, str>, Cow<'_, str>) = - (Cow::Borrowed("foo"), Cow::Borrowed("bar")); - const BODY_FIELD_ID: Option> = Some(Cow::Borrowed("id")); - const BODY_FIELD_DESC: Option> = Some(Cow::Borrowed("desc")); - const BODY_FIELD_ENC: ContentEncoding = ContentEncoding::SevenBit; - const BODY_FIELD_OCTETS: u32 = 1337; - - fn mock_body_text() -> (String, BodyStructure<'static>) { - ( - format!(r#"("TEXT" "PLAIN" {BODY_FIELDS} 42)"#), - BodyStructure::Text { - common: BodyContentCommon { - ty: ContentType { - ty: Cow::Borrowed("TEXT"), - subtype: Cow::Borrowed("PLAIN"), - params: Some(vec![BODY_FIELD_PARAM_PAIR]), - }, - disposition: None, - language: None, - location: None, - }, - other: BodyContentSinglePart { - md5: None, - transfer_encoding: BODY_FIELD_ENC, - octets: BODY_FIELD_OCTETS, - id: BODY_FIELD_ID, - description: BODY_FIELD_DESC, - }, - lines: 42, - extension: None, - }, - ) - } - - #[test] - fn test_body_param_data() { - assert_matches!(body_param(br#"NIL"#), Ok((EMPTY, None))); - - assert_matches!( - body_param(br#"("foo" "bar")"#), - Ok((EMPTY, Some(param))) => { - assert_eq!(param, vec![(Cow::Borrowed("foo"), Cow::Borrowed("bar"))]); - } - ); - } - - #[test] - fn test_body_lang_data() { - assert_matches!( - body_lang(br#""bob""#), - Ok((EMPTY, Some(langs))) => { - assert_eq!(langs, vec!["bob"]); - } - ); - - assert_matches!( - body_lang(br#"("one" "two")"#), - Ok((EMPTY, Some(langs))) => { - assert_eq!(langs, vec!["one", "two"]); - } - ); - - assert_matches!(body_lang(br#"NIL"#), Ok((EMPTY, None))); - } - - #[test] - fn test_body_extension_data() { - assert_matches!( - body_extension(br#""blah""#), - Ok((EMPTY, BodyExtension::Str(Some(Cow::Borrowed("blah"))))) - ); - - assert_matches!( - body_extension(br#"NIL"#), - Ok((EMPTY, BodyExtension::Str(None))) - ); - - assert_matches!( - body_extension(br#"("hello")"#), - Ok((EMPTY, BodyExtension::List(list))) => { - assert_eq!(list, vec![BodyExtension::Str(Some(Cow::Borrowed("hello")))]); - } - ); - - assert_matches!( - body_extension(br#"(1337)"#), - Ok((EMPTY, BodyExtension::List(list))) => { - assert_eq!(list, vec![BodyExtension::Num(1337)]); - } - ); - } - - #[test] - fn test_body_disposition_data() { - assert_matches!(body_disposition(br#"NIL"#), Ok((EMPTY, None))); - - assert_matches!( - body_disposition(br#"("attachment" ("FILENAME" "pages.pdf"))"#), - Ok((EMPTY, Some(disposition))) => { - assert_eq!(disposition, ContentDisposition { - ty: Cow::Borrowed("attachment"), - params: Some(vec![ - (Cow::Borrowed("FILENAME"), Cow::Borrowed("pages.pdf")) - ]) - }); - } - ); - } - - #[test] - fn test_body_structure_text() { - let (body_str, body_struct) = mock_body_text(); - - assert_matches!( - body(body_str.as_bytes()), - Ok((_, text)) => { - assert_eq!(text, body_struct); - } - ); - } - - #[test] - fn test_body_structure_text_with_ext() { - let body_str = format!(r#"("TEXT" "PLAIN" {BODY_FIELDS} 42 NIL NIL NIL NIL)"#); - let (_, text_body_struct) = mock_body_text(); - - assert_matches!( - body(body_str.as_bytes()), - Ok((_, text)) => { - assert_eq!(text, text_body_struct) - } - ); - } - - #[test] - fn test_body_structure_basic() { - const BODY: &[u8] = br#"("APPLICATION" "PDF" ("NAME" "pages.pdf") NIL NIL "BASE64" 38838 NIL ("attachment" ("FILENAME" "pages.pdf")) NIL NIL)"#; - - assert_matches!( - body(BODY), - Ok((_, basic)) => { - assert_eq!(basic, BodyStructure::Basic { - common: BodyContentCommon { - ty: ContentType { - ty: Cow::Borrowed("APPLICATION"), - subtype: Cow::Borrowed("PDF"), - params: Some(vec![(Cow::Borrowed("NAME"), Cow::Borrowed("pages.pdf"))]) - }, - disposition: Some(ContentDisposition { - ty: Cow::Borrowed("attachment"), - params: Some(vec![(Cow::Borrowed("FILENAME"), Cow::Borrowed("pages.pdf"))]) - }), - language: None, - location: None, - }, - other: BodyContentSinglePart { - transfer_encoding: ContentEncoding::Base64, - octets: 38838, - id: None, - md5: None, - description: None, - }, - extension: None, - }) - } - ); - } - - #[test] - fn test_body_structure_message() { - let (text_body_str, _) = mock_body_text(); - let envelope_str = r#"("Wed, 17 Jul 1996 02:23:25 -0700 (PDT)" "IMAP4rev1 WG mtg summary and minutes" (("Terry Gray" NIL "gray" "cac.washington.edu")) (("Terry Gray" NIL "gray" "cac.washington.edu")) (("Terry Gray" NIL "gray" "cac.washington.edu")) ((NIL NIL "imap" "cac.washington.edu")) ((NIL NIL "minutes" "CNRI.Reston.VA.US") ("John Klensin" NIL "KLENSIN" "MIT.EDU")) NIL NIL "")"#; - let body_str = - format!(r#"("MESSAGE" "RFC822" {BODY_FIELDS} {envelope_str} {text_body_str} 42)"#); - - assert_matches!( - body(body_str.as_bytes()), - Ok((_, BodyStructure::Message { .. })) - ); - } - - #[test] - fn test_body_structure_multipart() { - let (text_body_str1, text_body_struct1) = mock_body_text(); - let (text_body_str2, text_body_struct2) = mock_body_text(); - let body_str = - format!(r#"({text_body_str1}{text_body_str2} "ALTERNATIVE" NIL NIL NIL NIL)"#); - - assert_matches!( - body(body_str.as_bytes()), - Ok((_, multipart)) => { - assert_eq!(multipart, BodyStructure::Multipart { - common: BodyContentCommon { - ty: ContentType { - ty: Cow::Borrowed("MULTIPART"), - subtype: Cow::Borrowed("ALTERNATIVE"), - params: None - }, - language: None, - location: None, - disposition: None, - }, - bodies: vec![ - text_body_struct1, - text_body_struct2, - ], - extension: None - }); - } - ); - } -} diff --git a/vendor/imap-proto/src/parser/rfc3501/mod.rs b/vendor/imap-proto/src/parser/rfc3501/mod.rs deleted file mode 100644 index 6ef6cdc..0000000 --- a/vendor/imap-proto/src/parser/rfc3501/mod.rs +++ /dev/null @@ -1,853 +0,0 @@ -//! -//! https://tools.ietf.org/html/rfc3501 -//! -//! INTERNET MESSAGE ACCESS PROTOCOL -//! - -use std::borrow::Cow; -use std::str::from_utf8; - -use nom::{ - branch::alt, - bytes::streaming::{tag, tag_no_case, take_while, take_while1}, - character::streaming::char, - combinator::{map, map_res, opt, recognize, value}, - multi::{many0, many1}, - sequence::{delimited, pair, preceded, terminated, tuple}, - IResult, -}; - -use crate::{ - parser::{ - core::*, rfc2087, rfc2971, rfc3501::body::*, rfc3501::body_structure::*, rfc4314, rfc4315, - rfc4551, rfc5161, rfc5256, rfc5464, rfc7162, - }, - types::*, -}; - -use super::gmail; - -pub mod body; -pub mod body_structure; - -fn is_tag_char(c: u8) -> bool { - c != b'+' && is_astring_char(c) -} - -fn status_ok(i: &[u8]) -> IResult<&[u8], Status> { - map(tag_no_case("OK"), |_s| Status::Ok)(i) -} -fn status_no(i: &[u8]) -> IResult<&[u8], Status> { - map(tag_no_case("NO"), |_s| Status::No)(i) -} -fn status_bad(i: &[u8]) -> IResult<&[u8], Status> { - map(tag_no_case("BAD"), |_s| Status::Bad)(i) -} -fn status_preauth(i: &[u8]) -> IResult<&[u8], Status> { - map(tag_no_case("PREAUTH"), |_s| Status::PreAuth)(i) -} -fn status_bye(i: &[u8]) -> IResult<&[u8], Status> { - map(tag_no_case("BYE"), |_s| Status::Bye)(i) -} - -fn status(i: &[u8]) -> IResult<&[u8], Status> { - alt((status_ok, status_no, status_bad, status_preauth, status_bye))(i) -} - -pub(crate) fn mailbox(i: &[u8]) -> IResult<&[u8], &str> { - map(astring_utf8, |s| { - if s.eq_ignore_ascii_case("INBOX") { - "INBOX" - } else { - s - } - })(i) -} - -fn flag_extension(i: &[u8]) -> IResult<&[u8], &str> { - map_res( - recognize(pair(tag(b"\\"), take_while(is_atom_char))), - from_utf8, - )(i) -} - -pub(crate) fn flag(i: &[u8]) -> IResult<&[u8], &str> { - // Correct code is - // alt((flag_extension, atom))(i) - // - // Unfortunately, some unknown providers send the following response: - // * FLAGS (OIB-Seen-[Gmail]/All) - // - // As a workaround, ']' (resp-specials) is allowed here. - alt(( - flag_extension, - map_res(take_while1(is_astring_char), from_utf8), - ))(i) -} - -fn flag_list(i: &[u8]) -> IResult<&[u8], Vec>> { - // Correct code is - // parenthesized_list(flag)(i) - // - // Unfortunately, Zoho Mail Server (imap.zoho.com) sends the following response: - // * FLAGS (\Answered \Flagged \Deleted \Seen \Draft \*) - // - // As a workaround, "\*" is allowed here. - // - // Also, surgemail sends an additional space before the closing bracket: - // * FLAGS (\Answered \Flagged \Deleted \Draft \Seen $Forwarded ) - // - // As a workaround, optional spaces before the closing bracket are allowed. - parenthesized_list(map(flag_perm, Cow::Borrowed))(i) -} - -fn flag_perm(i: &[u8]) -> IResult<&[u8], &str> { - alt((map_res(tag(b"\\*"), from_utf8), flag))(i) -} - -fn resp_text_code_alert(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { - map(tag_no_case(b"ALERT"), |_| ResponseCode::Alert)(i) -} - -fn resp_text_code_badcharset(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { - map( - preceded( - tag_no_case(b"BADCHARSET"), - opt(preceded( - tag(b" "), - parenthesized_nonempty_list(map(astring_utf8, Cow::Borrowed)), - )), - ), - ResponseCode::BadCharset, - )(i) -} - -fn resp_text_code_capability(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { - map(capability_data, ResponseCode::Capabilities)(i) -} - -fn resp_text_code_parse(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { - map(tag_no_case(b"PARSE"), |_| ResponseCode::Parse)(i) -} - -fn resp_text_code_permanent_flags(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { - map( - preceded( - tag_no_case(b"PERMANENTFLAGS "), - parenthesized_list(map(flag_perm, Cow::Borrowed)), - ), - ResponseCode::PermanentFlags, - )(i) -} - -fn resp_text_code_read_only(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { - map(tag_no_case(b"READ-ONLY"), |_| ResponseCode::ReadOnly)(i) -} - -fn resp_text_code_read_write(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { - map(tag_no_case(b"READ-WRITE"), |_| ResponseCode::ReadWrite)(i) -} - -fn resp_text_code_try_create(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { - map(tag_no_case(b"TRYCREATE"), |_| ResponseCode::TryCreate)(i) -} - -fn resp_text_code_uid_validity(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { - map( - preceded(tag_no_case(b"UIDVALIDITY "), number), - ResponseCode::UidValidity, - )(i) -} - -fn resp_text_code_uid_next(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { - map( - preceded(tag_no_case(b"UIDNEXT "), number), - ResponseCode::UidNext, - )(i) -} - -fn resp_text_code_unseen(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { - map( - preceded(tag_no_case(b"UNSEEN "), number), - ResponseCode::Unseen, - )(i) -} - -fn resp_text_code(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { - // Per the spec, the closing tag should be "] ". - // See `resp_text` for more on why this is done differently. - delimited( - tag(b"["), - alt(( - resp_text_code_alert, - resp_text_code_badcharset, - resp_text_code_capability, - resp_text_code_parse, - resp_text_code_permanent_flags, - resp_text_code_uid_validity, - resp_text_code_uid_next, - resp_text_code_unseen, - resp_text_code_read_only, - resp_text_code_read_write, - resp_text_code_try_create, - rfc4551::resp_text_code_highest_mod_seq, - rfc4315::resp_text_code_append_uid, - rfc4315::resp_text_code_copy_uid, - rfc4315::resp_text_code_uid_not_sticky, - rfc5464::resp_text_code_metadata_long_entries, - rfc5464::resp_text_code_metadata_max_size, - rfc5464::resp_text_code_metadata_too_many, - rfc5464::resp_text_code_metadata_no_private, - )), - tag(b"]"), - )(i) -} - -fn capability(i: &[u8]) -> IResult<&[u8], Capability<'_>> { - alt(( - map(tag_no_case(b"IMAP4rev1"), |_| Capability::Imap4rev1), - map( - map(preceded(tag_no_case(b"AUTH="), atom), Cow::Borrowed), - Capability::Auth, - ), - map(map(atom, Cow::Borrowed), Capability::Atom), - ))(i) -} - -fn ensure_capabilities_contains_imap4rev( - capabilities: Vec>, -) -> Result>, ()> { - if capabilities.contains(&Capability::Imap4rev1) { - Ok(capabilities) - } else { - Err(()) - } -} - -fn capability_data(i: &[u8]) -> IResult<&[u8], Vec>> { - map_res( - preceded( - tag_no_case(b"CAPABILITY"), - many0(preceded(char(' '), capability)), - ), - ensure_capabilities_contains_imap4rev, - )(i) -} - -fn mailbox_data_search(i: &[u8]) -> IResult<&[u8], MailboxDatum<'_>> { - map( - // Technically, trailing whitespace is not allowed here, but multiple - // email servers in the wild seem to have it anyway (see #34, #108). - terminated( - preceded(tag_no_case(b"SEARCH"), many0(preceded(tag(" "), number))), - opt(tag(" ")), - ), - MailboxDatum::Search, - )(i) -} - -fn mailbox_data_flags(i: &[u8]) -> IResult<&[u8], MailboxDatum<'_>> { - map( - preceded(tag_no_case("FLAGS "), flag_list), - MailboxDatum::Flags, - )(i) -} - -fn mailbox_data_exists(i: &[u8]) -> IResult<&[u8], MailboxDatum<'_>> { - map( - terminated(number, tag_no_case(" EXISTS")), - MailboxDatum::Exists, - )(i) -} - -fn name_attribute(i: &[u8]) -> IResult<&[u8], NameAttribute<'_>> { - alt(( - // RFC 3501 - value(NameAttribute::NoInferiors, tag_no_case(b"\\Noinferiors")), - value(NameAttribute::NoSelect, tag_no_case(b"\\Noselect")), - value(NameAttribute::Marked, tag_no_case(b"\\Marked")), - value(NameAttribute::Unmarked, tag_no_case(b"\\Unmarked")), - // RFC 6154 - value(NameAttribute::All, tag_no_case(b"\\All")), - value(NameAttribute::Archive, tag_no_case(b"\\Archive")), - value(NameAttribute::Drafts, tag_no_case(b"\\Drafts")), - value(NameAttribute::Flagged, tag_no_case(b"\\Flagged")), - value(NameAttribute::Junk, tag_no_case(b"\\Junk")), - value(NameAttribute::Sent, tag_no_case(b"\\Sent")), - value(NameAttribute::Trash, tag_no_case(b"\\Trash")), - // Extensions not supported by this crate - map( - map_res( - recognize(pair(tag(b"\\"), take_while(is_atom_char))), - from_utf8, - ), - |s| NameAttribute::Extension(Cow::Borrowed(s)), - ), - ))(i) -} - -#[allow(clippy::type_complexity)] -fn mailbox_list(i: &[u8]) -> IResult<&[u8], (Vec>, Option<&str>, &str)> { - map( - tuple(( - parenthesized_list(name_attribute), - tag(b" "), - alt((map(quoted_utf8, Some), map(nil, |_| None))), - tag(b" "), - mailbox, - )), - |(name_attributes, _, delimiter, _, name)| (name_attributes, delimiter, name), - )(i) -} - -fn mailbox_data_list(i: &[u8]) -> IResult<&[u8], MailboxDatum<'_>> { - map(preceded(tag_no_case("LIST "), mailbox_list), |data| { - MailboxDatum::List { - name_attributes: data.0, - delimiter: data.1.map(Cow::Borrowed), - name: Cow::Borrowed(data.2), - } - })(i) -} - -fn mailbox_data_lsub(i: &[u8]) -> IResult<&[u8], MailboxDatum<'_>> { - map(preceded(tag_no_case("LSUB "), mailbox_list), |data| { - MailboxDatum::List { - name_attributes: data.0, - delimiter: data.1.map(Cow::Borrowed), - name: Cow::Borrowed(data.2), - } - })(i) -} - -// Unlike `status_att` in the RFC syntax, this includes the value, -// so that it can return a valid enum object instead of just a key. -fn status_att(i: &[u8]) -> IResult<&[u8], StatusAttribute> { - alt(( - rfc4551::status_att_val_highest_mod_seq, - map( - preceded(tag_no_case("MESSAGES "), number), - StatusAttribute::Messages, - ), - map( - preceded(tag_no_case("RECENT "), number), - StatusAttribute::Recent, - ), - map( - preceded(tag_no_case("UIDNEXT "), number), - StatusAttribute::UidNext, - ), - map( - preceded(tag_no_case("UIDVALIDITY "), number), - StatusAttribute::UidValidity, - ), - map( - preceded(tag_no_case("UNSEEN "), number), - StatusAttribute::Unseen, - ), - ))(i) -} - -fn status_att_list(i: &[u8]) -> IResult<&[u8], Vec> { - // RFC 3501 specifies that the list is non-empty in the formal grammar - // status-att-list = status-att SP number *(SP status-att SP number) - // but mail.163.com sends an empty list in STATUS response anyway. - parenthesized_list(status_att)(i) -} - -fn mailbox_data_status(i: &[u8]) -> IResult<&[u8], MailboxDatum<'_>> { - map( - tuple((tag_no_case("STATUS "), mailbox, tag(" "), status_att_list)), - |(_, mailbox, _, status)| MailboxDatum::Status { - mailbox: Cow::Borrowed(mailbox), - status, - }, - )(i) -} - -fn mailbox_data_recent(i: &[u8]) -> IResult<&[u8], MailboxDatum<'_>> { - map( - terminated(number, tag_no_case(" RECENT")), - MailboxDatum::Recent, - )(i) -} - -fn mailbox_data(i: &[u8]) -> IResult<&[u8], MailboxDatum<'_>> { - alt(( - mailbox_data_flags, - mailbox_data_exists, - mailbox_data_list, - mailbox_data_lsub, - mailbox_data_status, - mailbox_data_recent, - mailbox_data_search, - gmail::mailbox_data_gmail_labels, - gmail::mailbox_data_gmail_msgid, - gmail::mailbox_data_gmail_thrid, - rfc5256::mailbox_data_sort, - ))(i) -} - -// An address structure is a parenthesized list that describes an -// electronic mail address. -fn address(i: &[u8]) -> IResult<&[u8], Address<'_>> { - paren_delimited(map( - tuple(( - nstring, - tag(" "), - nstring, - tag(" "), - nstring, - tag(" "), - nstring, - )), - |(name, _, adl, _, mailbox, _, host)| Address { - name: name.map(Cow::Borrowed), - adl: adl.map(Cow::Borrowed), - mailbox: mailbox.map(Cow::Borrowed), - host: host.map(Cow::Borrowed), - }, - ))(i) -} - -fn opt_addresses(i: &[u8]) -> IResult<&[u8], Option>>> { - alt(( - map(nil, |_s| None), - map( - paren_delimited(many1(terminated(address, opt(char(' '))))), - Some, - ), - ))(i) -} - -// envelope = "(" env-date SP env-subject SP env-from SP -// env-sender SP env-reply-to SP env-to SP env-cc SP -// env-bcc SP env-in-reply-to SP env-message-id ")" -// -// env-bcc = "(" 1*address ")" / nil -// -// env-cc = "(" 1*address ")" / nil -// -// env-date = nstring -// -// env-from = "(" 1*address ")" / nil -// -// env-in-reply-to = nstring -// -// env-message-id = nstring -// -// env-reply-to = "(" 1*address ")" / nil -// -// env-sender = "(" 1*address ")" / nil -// -// env-subject = nstring -// -// env-to = "(" 1*address ")" / nil -pub(crate) fn envelope(i: &[u8]) -> IResult<&[u8], Envelope<'_>> { - paren_delimited(map( - tuple(( - nstring, - tag(" "), - nstring, - tag(" "), - opt_addresses, - tag(" "), - opt_addresses, - tag(" "), - opt_addresses, - tag(" "), - opt_addresses, - tag(" "), - opt_addresses, - tag(" "), - opt_addresses, - tag(" "), - nstring, - tag(" "), - nstring, - )), - |( - date, - _, - subject, - _, - from, - _, - sender, - _, - reply_to, - _, - to, - _, - cc, - _, - bcc, - _, - in_reply_to, - _, - message_id, - )| Envelope { - date: date.map(Cow::Borrowed), - subject: subject.map(Cow::Borrowed), - from, - sender, - reply_to, - to, - cc, - bcc, - in_reply_to: in_reply_to.map(Cow::Borrowed), - message_id: message_id.map(Cow::Borrowed), - }, - ))(i) -} - -fn msg_att_envelope(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { - map(preceded(tag_no_case("ENVELOPE "), envelope), |envelope| { - AttributeValue::Envelope(Box::new(envelope)) - })(i) -} - -fn msg_att_internal_date(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { - map( - preceded(tag_no_case("INTERNALDATE "), nstring_utf8), - |date| AttributeValue::InternalDate(Cow::Borrowed(date.unwrap())), - )(i) -} - -fn msg_att_flags(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { - map( - preceded(tag_no_case("FLAGS "), flag_list), - AttributeValue::Flags, - )(i) -} - -fn msg_att_rfc822(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { - map(preceded(tag_no_case("RFC822 "), nstring), |v| { - AttributeValue::Rfc822(v.map(Cow::Borrowed)) - })(i) -} - -fn msg_att_rfc822_header(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { - // extra space workaround for DavMail - map( - tuple((tag_no_case("RFC822.HEADER "), opt(tag(b" ")), nstring)), - |(_, _, raw)| AttributeValue::Rfc822Header(raw.map(Cow::Borrowed)), - )(i) -} - -fn msg_att_rfc822_size(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { - map( - preceded(tag_no_case("RFC822.SIZE "), number), - AttributeValue::Rfc822Size, - )(i) -} - -fn msg_att_rfc822_text(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { - map(preceded(tag_no_case("RFC822.TEXT "), nstring), |v| { - AttributeValue::Rfc822Text(v.map(Cow::Borrowed)) - })(i) -} - -fn msg_att_uid(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { - map(preceded(tag_no_case("UID "), number), AttributeValue::Uid)(i) -} - -// msg-att = "(" (msg-att-dynamic / msg-att-static) -// *(SP (msg-att-dynamic / msg-att-static)) ")" -// -// msg-att-dynamic = "FLAGS" SP "(" [flag-fetch *(SP flag-fetch)] ")" -// ; MAY change for a message -// -// msg-att-static = "ENVELOPE" SP envelope / "INTERNALDATE" SP date-time / -// "RFC822" [".HEADER" / ".TEXT"] SP nstring / -// "RFC822.SIZE" SP number / -// "BODY" ["STRUCTURE"] SP body / -// "BODY" section ["<" number ">"] SP nstring / -// "UID" SP uniqueid -// ; MUST NOT change for a message -fn msg_att(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { - alt(( - msg_att_body_section, - msg_att_body_structure, - msg_att_envelope, - msg_att_internal_date, - msg_att_flags, - rfc4551::msg_att_mod_seq, - msg_att_rfc822, - msg_att_rfc822_header, - msg_att_rfc822_size, - msg_att_rfc822_text, - msg_att_uid, - gmail::msg_att_gmail_labels, - gmail::msg_att_gmail_msgid, - gmail::msg_att_gmail_thrid, - ))(i) -} - -fn msg_att_list(i: &[u8]) -> IResult<&[u8], Vec>> { - parenthesized_nonempty_list(msg_att)(i) -} - -// message-data = nz-number SP ("EXPUNGE" / ("FETCH" SP msg-att)) -fn message_data_fetch(i: &[u8]) -> IResult<&[u8], Response<'_>> { - map( - tuple((number, tag_no_case(" FETCH "), msg_att_list)), - |(num, _, attrs)| Response::Fetch(num, attrs), - )(i) -} - -// UIDFETCH (RFC 9586, IMAP UIDONLY extension): identical to FETCH, but the -// leading number is the message UID rather than a sequence number, and the -// UID message-data item may be omitted (it is redundant). Surface it as a -// normal `Response::Fetch` with a synthesized `Uid` attribute so existing -// FETCH consumers work unchanged and `.uid()` resolves. -// [agentmail local patch — see docs/patches/] -fn message_data_uidfetch(i: &[u8]) -> IResult<&[u8], Response<'_>> { - map( - tuple((number, tag_no_case(" UIDFETCH "), msg_att_list)), - |(uid, _, mut attrs)| { - if !attrs - .iter() - .any(|attr| matches!(attr, AttributeValue::Uid(_))) - { - attrs.insert(0, AttributeValue::Uid(uid)); - } - Response::Fetch(uid, attrs) - }, - )(i) -} - -// message-data = nz-number SP ("EXPUNGE" / ("FETCH" SP msg-att)) -fn message_data_expunge(i: &[u8]) -> IResult<&[u8], u32> { - terminated(number, tag_no_case(" EXPUNGE"))(i) -} - -// tag = 1* -fn imap_tag(i: &[u8]) -> IResult<&[u8], RequestId> { - map(map_res(take_while1(is_tag_char), from_utf8), |s| { - RequestId(s.to_string()) - })(i) -} - -// This is not quite according to spec, which mandates the following: -// ["[" resp-text-code "]" SP] text -// However, examples in RFC 4551 (Conditional STORE) counteract this by giving -// examples of `resp-text` that do not include the trailing space and text. -fn resp_text(i: &[u8]) -> IResult<&[u8], (Option>, Option<&str>)> { - map(tuple((opt(resp_text_code), text)), |(code, text)| { - let res = if text.is_empty() { - None - } else if code.is_some() { - Some(&text[1..]) - } else { - Some(text) - }; - (code, res) - })(i) -} - -// an response-text if it is at the end of a response. Empty text is then allowed without the normally needed trailing space. -fn trailing_resp_text(i: &[u8]) -> IResult<&[u8], (Option>, Option<&str>)> { - map(opt(tuple((tag(b" "), resp_text))), |resptext| { - resptext.map(|(_, tuple)| tuple).unwrap_or((None, None)) - })(i) -} - -// continue-req = "+" SP (resp-text / base64) CRLF -pub(crate) fn continue_req(i: &[u8]) -> IResult<&[u8], Response<'_>> { - // Some servers do not send the space :/ - // TODO: base64 - map( - tuple((tag("+"), opt(tag(" ")), resp_text, tag("\r\n"))), - |(_, _, text, _)| Response::Continue { - code: text.0, - information: text.1.map(Cow::Borrowed), - }, - )(i) -} - -// response-tagged = tag SP resp-cond-state CRLF -// -// resp-cond-state = ("OK" / "NO" / "BAD") SP resp-text -// ; Status condition -pub(crate) fn response_tagged(i: &[u8]) -> IResult<&[u8], Response<'_>> { - map( - tuple(( - imap_tag, - tag(b" "), - status, - trailing_resp_text, - tag(b"\r\n"), - )), - |(tag, _, status, text, _)| Response::Done { - tag, - status, - code: text.0, - information: text.1.map(Cow::Borrowed), - }, - )(i) -} - -// resp-cond-auth = ("OK" / "PREAUTH") SP resp-text -// ; Authentication condition -// -// resp-cond-bye = "BYE" SP resp-text -// -// resp-cond-state = ("OK" / "NO" / "BAD") SP resp-text -// ; Status condition -fn resp_cond(i: &[u8]) -> IResult<&[u8], Response<'_>> { - map(tuple((status, trailing_resp_text)), |(status, text)| { - Response::Data { - status, - code: text.0, - information: text.1.map(Cow::Borrowed), - } - })(i) -} - -// response-data = "*" SP (resp-cond-state / resp-cond-bye / -// mailbox-data / message-data / capability-data / quota) CRLF -pub(crate) fn response_data(i: &[u8]) -> IResult<&[u8], Response<'_>> { - delimited( - tag(b"* "), - alt(( - resp_cond, - map(mailbox_data, Response::MailboxData), - map(message_data_expunge, Response::Expunge), - message_data_fetch, - message_data_uidfetch, - map(capability_data, Response::Capabilities), - rfc5161::resp_enabled, - rfc5464::metadata_solicited, - rfc5464::metadata_unsolicited, - rfc7162::resp_vanished, - rfc2087::quota, - rfc2087::quota_root, - rfc2971::resp_id, - rfc4314::acl, - rfc4314::list_rights, - rfc4314::my_rights, - )), - preceded( - many0(tag(b" ")), // Outlook server sometimes sends whitespace at the end of STATUS response. - tag(b"\r\n"), - ), - )(i) -} - -#[cfg(test)] -mod tests { - use crate::types::*; - use assert_matches::assert_matches; - use std::borrow::Cow; - - #[test] - fn test_list() { - match super::mailbox(b"iNboX ") { - Ok((_, mb)) => { - assert_eq!(mb, "INBOX"); - } - rsp => panic!("unexpected response {rsp:?}"), - } - } - - #[test] - fn test_envelope() { - let env = br#"ENVELOPE ("Wed, 17 Jul 1996 02:23:25 -0700 (PDT)" "IMAP4rev1 WG mtg summary and minutes" (("Terry Gray" NIL "gray" "cac.washington.edu")) (("Terry Gray" NIL "gray" "cac.washington.edu")) (("Terry Gray" NIL "gray" "cac.washington.edu")) ((NIL NIL "imap" "cac.washington.edu")) ((NIL NIL "minutes" "CNRI.Reston.VA.US") ("John Klensin" NIL "KLENSIN" "MIT.EDU")) NIL NIL "") "#; - match super::msg_att_envelope(env) { - Ok((_, AttributeValue::Envelope(_))) => {} - rsp => panic!("unexpected response {rsp:?}"), - } - } - - #[test] - fn test_opt_addresses() { - let addr = b"((NIL NIL \"minutes\" \"CNRI.Reston.VA.US\") (\"John Klensin\" NIL \"KLENSIN\" \"MIT.EDU\")) "; - match super::opt_addresses(addr) { - Ok((_, _addresses)) => {} - rsp => panic!("unexpected response {rsp:?}"), - } - } - - #[test] - fn test_opt_addresses_no_space() { - let addr = - br#"((NIL NIL "test" "example@example.com")(NIL NIL "test" "example@example.com"))"#; - match super::opt_addresses(addr) { - Ok((_, _addresses)) => {} - rsp => panic!("unexpected response {rsp:?}"), - } - } - - #[test] - fn test_addresses() { - match super::address(b"(\"John Klensin\" NIL \"KLENSIN\" \"MIT.EDU\") ") { - Ok((_, _address)) => {} - rsp => panic!("unexpected response {rsp:?}"), - } - - // Literal non-UTF8 address - match super::address(b"({12}\r\nJoh\xff Klensin NIL \"KLENSIN\" \"MIT.EDU\") ") { - Ok((_, _address)) => {} - rsp => panic!("unexpected response {rsp:?}"), - } - } - - #[test] - fn test_capability_data() { - // Minimal capabilities - assert_matches!( - super::capability_data(b"CAPABILITY IMAP4rev1\r\n"), - Ok((_, capabilities)) => { - assert_eq!(capabilities, vec![Capability::Imap4rev1]) - } - ); - - assert_matches!( - super::capability_data(b"CAPABILITY XPIG-LATIN IMAP4rev1 STARTTLS AUTH=GSSAPI\r\n"), - Ok((_, capabilities)) => { - assert_eq!(capabilities, vec![ - Capability::Atom(Cow::Borrowed("XPIG-LATIN")), - Capability::Imap4rev1, - Capability::Atom(Cow::Borrowed("STARTTLS")), - Capability::Auth(Cow::Borrowed("GSSAPI")), - ]) - } - ); - - assert_matches!( - super::capability_data(b"CAPABILITY IMAP4rev1 AUTH=GSSAPI AUTH=PLAIN\r\n"), - Ok((_, capabilities)) => { - assert_eq!(capabilities, vec![ - Capability::Imap4rev1, - Capability::Auth(Cow::Borrowed("GSSAPI")), - Capability::Auth(Cow::Borrowed("PLAIN")), - ]) - } - ); - - // Capability command must contain IMAP4rev1 - assert_matches!( - super::capability_data(b"CAPABILITY AUTH=GSSAPI AUTH=PLAIN\r\n"), - Err(_) - ); - } - - #[test] - fn test_surgemail_select_flags() { - // Tests workaround for surgemail with space before closing bracket - assert_matches!( - super::flag_list(b"(\\Answered \\Flagged \\Deleted \\Draft \\Seen $Forwarded )"), - Ok(([], flags)) => { - assert_eq!(flags, vec![ - "\\Answered", - "\\Flagged", - "\\Deleted", - "\\Draft", - "\\Seen", - "$Forwarded" - ]) - } - ); - } -} diff --git a/vendor/imap-proto/src/parser/rfc4314.rs b/vendor/imap-proto/src/parser/rfc4314.rs deleted file mode 100644 index 1c02614..0000000 --- a/vendor/imap-proto/src/parser/rfc4314.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! -//! Current -//! https://tools.ietf.org/html/rfc4314 -//! -//! Original -//! https://tools.ietf.org/html/rfc2086 -//! -//! The IMAP ACL Extension -//! - -use std::borrow::Cow; - -use nom::{ - bytes::streaming::tag_no_case, - character::complete::{space0, space1}, - combinator::map, - multi::separated_list0, - sequence::{preceded, separated_pair, tuple}, - IResult, -}; - -use crate::parser::core::astring_utf8; -use crate::parser::rfc3501::mailbox; -use crate::types::*; - -/// 3.6. ACL Response -/// ```ignore -/// acl_response ::= "ACL" SP mailbox SP acl_list -/// ``` -pub(crate) fn acl(i: &[u8]) -> IResult<&[u8], Response<'_>> { - let (rest, (_, _, mailbox, acls)) = tuple(( - tag_no_case("ACL"), - space1, - map(mailbox, Cow::Borrowed), - acl_list, - ))(i)?; - - Ok((rest, Response::Acl(Acl { mailbox, acls }))) -} - -/// ```ignore -/// acl_list ::= *(SP acl_entry) -/// ``` -fn acl_list(i: &[u8]) -> IResult<&[u8], Vec>> { - preceded(space0, separated_list0(space1, acl_entry))(i) -} - -/// ```ignore -/// acl_entry ::= SP identifier SP rights -/// ``` -fn acl_entry(i: &[u8]) -> IResult<&[u8], AclEntry<'_>> { - let (rest, (identifier, rights)) = separated_pair( - map(astring_utf8, Cow::Borrowed), - space1, - map(astring_utf8, map_text_to_rights), - )(i)?; - - Ok((rest, AclEntry { identifier, rights })) -} - -/// 3.7. LISTRIGHTS Response -/// ```ignore -/// list_rights_response ::= "LISTRIGHTS" SP mailbox SP identifier SP required_rights *(SP optional_rights) -/// ``` -pub(crate) fn list_rights(i: &[u8]) -> IResult<&[u8], Response<'_>> { - let (rest, (_, _, mailbox, _, identifier, _, required, optional)) = tuple(( - tag_no_case("LISTRIGHTS"), - space1, - map(mailbox, Cow::Borrowed), - space1, - map(astring_utf8, Cow::Borrowed), - space1, - map(astring_utf8, map_text_to_rights), - list_rights_optional, - ))(i)?; - - Ok(( - rest, - Response::ListRights(ListRights { - mailbox, - identifier, - required, - optional, - }), - )) -} - -fn list_rights_optional(i: &[u8]) -> IResult<&[u8], Vec> { - let (rest, items) = preceded(space0, separated_list0(space1, astring_utf8))(i)?; - - Ok(( - rest, - items - .into_iter() - .flat_map(|s| s.chars().map(|c| c.into())) - .collect(), - )) -} - -/// 3.7. MYRIGHTS Response -/// ```ignore -/// my_rights_response ::= "MYRIGHTS" SP mailbox SP rights -/// ``` -pub(crate) fn my_rights(i: &[u8]) -> IResult<&[u8], Response<'_>> { - let (rest, (_, _, mailbox, _, rights)) = tuple(( - tag_no_case("MYRIGHTS"), - space1, - map(mailbox, Cow::Borrowed), - space1, - map(astring_utf8, map_text_to_rights), - ))(i)?; - - Ok((rest, Response::MyRights(MyRights { mailbox, rights }))) -} - -/// helper routine to map a string to a vec of AclRights -fn map_text_to_rights(i: &str) -> Vec { - i.chars().map(|c| c.into()).collect() -} diff --git a/vendor/imap-proto/src/parser/rfc2087.rs b/vendor/imap-proto/src/rfc2087.rs similarity index 66% rename from vendor/imap-proto/src/parser/rfc2087.rs rename to vendor/imap-proto/src/rfc2087.rs index b73949f..95633e9 100644 --- a/vendor/imap-proto/src/parser/rfc2087.rs +++ b/vendor/imap-proto/src/rfc2087.rs @@ -1,5 +1,5 @@ //! -//! https://tools.ietf.org/html/rfc2087 +//! //! //! IMAP4 QUOTA extension //! @@ -13,27 +13,45 @@ use nom::{ combinator::map, multi::many0, multi::separated_list0, - sequence::{delimited, preceded, tuple}, - IResult, + sequence::{delimited, preceded}, + IResult, Parser, }; -use crate::parser::core::astring_utf8; -use crate::types::*; +use crate::core::{astring_utf8, to_owned_cow}; +use crate::Response; use super::core::number_64; +/// 5.1. QUOTA Response () +#[derive(Debug, Eq, PartialEq, Hash, Clone)] +pub struct Quota<'a> { + /// quota root name + pub root_name: Cow<'a, str>, + pub resources: Vec>, +} + +impl<'a> Quota<'a> { + pub fn into_owned(self) -> Quota<'static> { + Quota { + root_name: to_owned_cow(self.root_name), + resources: self.resources.into_iter().map(|r| r.into_owned()).collect(), + } + } +} + /// 5.1. QUOTA Response /// ```ignore /// quota_response ::= "QUOTA" SP astring SP quota_list /// ``` pub(crate) fn quota(i: &[u8]) -> IResult<&[u8], Response<'_>> { - let (rest, (_, _, root_name, _, resources)) = tuple(( + let (rest, (_, _, root_name, _, resources)) = ( tag_no_case("QUOTA"), space1, - map(astring_utf8, Cow::Borrowed), + astring_utf8, space1, quota_list, - ))(i)?; + ) + .parse(i)?; Ok(( rest, @@ -48,25 +66,99 @@ pub(crate) fn quota(i: &[u8]) -> IResult<&[u8], Response<'_>> { /// quota_list ::= "(" #quota_resource ")" /// ``` pub(crate) fn quota_list(i: &[u8]) -> IResult<&[u8], Vec>> { - delimited(tag("("), separated_list0(space1, quota_resource), tag(")"))(i) + delimited( + tag("("), + separated_list0(space1, QuotaResource::parse), + tag(")"), + ) + .parse(i) } -/// ```ignore -/// quota_resource ::= atom SP number SP number -/// ``` -pub(crate) fn quota_resource(i: &[u8]) -> IResult<&[u8], QuotaResource<'_>> { - let (rest, (name, _, usage, _, limit)) = - tuple((quota_resource_name, space1, number_64, space1, number_64))(i)?; +/// 5.1. QUOTA Response () +#[derive(Debug, Eq, PartialEq, Hash, Clone)] +pub struct QuotaResource<'a> { + pub name: QuotaResourceName<'a>, + /// current usage of the resource + pub usage: u64, + /// resource limit + pub limit: u64, +} + +impl<'a> QuotaResource<'a> { + /// ```ignore + /// quota_resource ::= atom SP number SP number + /// ``` + pub(crate) fn parse(i: &'a [u8]) -> IResult<&'a [u8], Self> { + let (rest, (name, _, usage, _, limit)) = ( + QuotaResourceName::parse, + space1, + number_64, + space1, + number_64, + ) + .parse(i)?; + + Ok((rest, QuotaResource { name, usage, limit })) + } - Ok((rest, QuotaResource { name, usage, limit })) + pub fn into_owned(self) -> QuotaResource<'static> { + QuotaResource { + name: self.name.into_owned(), + usage: self.usage, + limit: self.limit, + } + } +} + +/// +#[derive(Debug, Eq, PartialEq, Hash, Clone)] +pub enum QuotaResourceName<'a> { + /// Sum of messages' RFC822.SIZE, in units of 1024 octets + Storage, + /// Number of messages + Message, + Atom(Cow<'a, str>), } -pub(crate) fn quota_resource_name(i: &[u8]) -> IResult<&[u8], QuotaResourceName<'_>> { - alt(( - map(tag_no_case("STORAGE"), |_| QuotaResourceName::Storage), - map(tag_no_case("MESSAGE"), |_| QuotaResourceName::Message), - map(map(astring_utf8, Cow::Borrowed), QuotaResourceName::Atom), - ))(i) +impl<'a> QuotaResourceName<'a> { + pub(crate) fn parse(i: &'a [u8]) -> IResult<&'a [u8], Self> { + alt(( + map(tag_no_case("STORAGE"), |_| QuotaResourceName::Storage), + map(tag_no_case("MESSAGE"), |_| QuotaResourceName::Message), + map(astring_utf8, QuotaResourceName::Atom), + )) + .parse(i) + } + + pub fn into_owned(self) -> QuotaResourceName<'static> { + match self { + QuotaResourceName::Message => QuotaResourceName::Message, + QuotaResourceName::Storage => QuotaResourceName::Storage, + QuotaResourceName::Atom(v) => QuotaResourceName::Atom(to_owned_cow(v)), + } + } +} + +/// 5.2. QUOTAROOT Response () +#[derive(Debug, Eq, PartialEq, Hash, Clone)] +pub struct QuotaRoot<'a> { + /// mailbox name + pub mailbox_name: Cow<'a, str>, + /// zero or more quota root names + pub quota_root_names: Vec>, +} + +impl<'a> QuotaRoot<'a> { + pub fn into_owned(self) -> QuotaRoot<'static> { + QuotaRoot { + mailbox_name: to_owned_cow(self.mailbox_name), + quota_root_names: self + .quota_root_names + .into_iter() + .map(to_owned_cow) + .collect(), + } + } } /// 5.2. QUOTAROOT Response @@ -74,12 +166,13 @@ pub(crate) fn quota_resource_name(i: &[u8]) -> IResult<&[u8], QuotaResourceName< /// quotaroot_response ::= "QUOTAROOT" SP astring *(SP astring) /// ``` pub(crate) fn quota_root(i: &[u8]) -> IResult<&[u8], Response<'_>> { - let (rest, (_, _, mailbox_name, quota_root_names)) = tuple(( + let (rest, (_, _, mailbox_name, quota_root_names)) = ( tag_no_case("QUOTAROOT"), space1, - map(astring_utf8, Cow::Borrowed), - many0(preceded(space1, map(astring_utf8, Cow::Borrowed))), - ))(i)?; + astring_utf8, + many0(preceded(space1, astring_utf8)), + ) + .parse(i)?; Ok(( rest, @@ -145,7 +238,7 @@ mod tests { #[test] fn test_quota_response_data() { assert_matches!( - crate::parser::rfc3501::response_data(b"* QUOTA \"\" (STORAGE 10 512)\r\n"), + crate::rfc3501::response_data(b"* QUOTA \"\" (STORAGE 10 512)\r\n"), Ok((_, r)) => { assert_eq!( r, @@ -210,7 +303,7 @@ mod tests { #[test] fn test_quota_root_response_data() { assert_matches!( - crate::parser::rfc3501::response_data("* QUOTAROOT INBOX \"\"\r\n".as_bytes()), + crate::rfc3501::response_data("* QUOTAROOT INBOX \"\"\r\n".as_bytes()), Ok((_, r)) => { assert_eq!( r, @@ -224,7 +317,7 @@ mod tests { } fn terminated_quota_root(i: &[u8]) -> IResult<&[u8], Response<'_>> { - nom::sequence::terminated(quota_root, nom::bytes::streaming::tag("\r\n"))(i) + nom::sequence::terminated(quota_root, nom::bytes::streaming::tag("\r\n")).parse(i) } #[test] diff --git a/vendor/imap-proto/src/parser/rfc2971.rs b/vendor/imap-proto/src/rfc2971.rs similarity index 85% rename from vendor/imap-proto/src/parser/rfc2971.rs rename to vendor/imap-proto/src/rfc2971.rs index e075cbf..ad42207 100644 --- a/vendor/imap-proto/src/parser/rfc2971.rs +++ b/vendor/imap-proto/src/rfc2971.rs @@ -1,6 +1,6 @@ //! //! -//! https://tools.ietf.org/html/rfc2971 +//! //! //! The IMAP4 ID extension //! @@ -13,33 +13,34 @@ use nom::{ character::complete::{char, space0, space1}, combinator::map, multi::many0, - sequence::{preceded, separated_pair, tuple}, - IResult, + sequence::{preceded, separated_pair}, + IResult, Parser, }; use crate::{ - parser::core::{nil, nstring_utf8, string_utf8}, + core::{nil, nstring_utf8, string_utf8}, Response, }; // A single id parameter (field and value). // Format: string SPACE nstring // [RFC2971 - Formal Syntax](https://tools.ietf.org/html/rfc2971#section-4) -fn id_param(i: &[u8]) -> IResult<&[u8], (&str, Option<&str>)> { - separated_pair(string_utf8, space1, nstring_utf8)(i) +#[allow(clippy::type_complexity)] +fn id_param(i: &[u8]) -> IResult<&[u8], (Cow<'_, str>, Option>)> { + separated_pair(string_utf8, space1, nstring_utf8).parse(i) } // The non-nil case of id parameter list. // Format: "(" #(string SPACE nstring) ")" // [RFC2971 - Formal Syntax](https://tools.ietf.org/html/rfc2971#section-4) -fn id_param_list_not_nil(i: &[u8]) -> IResult<&[u8], HashMap<&str, &str>> { +fn id_param_list_not_nil(i: &[u8]) -> IResult<&[u8], HashMap, Cow<'_, str>>> { map( - tuple(( + ( char('('), id_param, - many0(tuple((space1, id_param))), + many0((space1, id_param)), preceded(space0, char(')')), - )), + ), |(_, first_param, rest_params, _)| { let mut params = vec![first_param]; for (_, p) in rest_params { @@ -52,32 +53,28 @@ fn id_param_list_not_nil(i: &[u8]) -> IResult<&[u8], HashMap<&str, &str>> { .map(|(k, v)| (k, v.unwrap())) .collect() }, - )(i) + ) + .parse(i) } // The id parameter list of all cases // id_params_list ::= "(" #(string SPACE nstring) ")" / nil // [RFC2971 - Formal Syntax](https://tools.ietf.org/html/rfc2971#section-4) -fn id_param_list(i: &[u8]) -> IResult<&[u8], Option>> { - alt((map(id_param_list_not_nil, Some), map(nil, |_| None)))(i) +#[allow(clippy::type_complexity)] +fn id_param_list(i: &[u8]) -> IResult<&[u8], Option, Cow<'_, str>>>> { + alt((map(id_param_list_not_nil, Some), map(nil, |_| None))).parse(i) } // id_response ::= "ID" SPACE id_params_list // [RFC2971 - Formal Syntax](https://tools.ietf.org/html/rfc2971#section-4) pub(crate) fn resp_id(i: &[u8]) -> IResult<&[u8], Response<'_>> { let (rest, map) = map( - tuple((tag_no_case("ID"), space1, id_param_list)), + (tag_no_case("ID"), space1, id_param_list), |(_id, _sp, p)| p, - )(i)?; + ) + .parse(i)?; - Ok(( - rest, - Response::Id(map.map(|m| { - m.into_iter() - .map(|(k, v)| (Cow::Borrowed(k), Cow::Borrowed(v))) - .collect() - })), - )) + Ok((rest, Response::Id(map))) } #[cfg(test)] @@ -91,7 +88,7 @@ mod tests { id_param(br#""name" "Cyrus""#), Ok((_, (name, value))) => { assert_eq!(name, "name"); - assert_eq!(value, Some("Cyrus")); + assert_eq!(value.as_deref(), Some("Cyrus")); } ); @@ -99,7 +96,7 @@ mod tests { id_param(br#""name" NIL"#), Ok((_, (name, value))) => { assert_eq!(name, "name"); - assert_eq!(value, None); + assert!(value.is_none()); } ); } @@ -118,6 +115,7 @@ mod tests { ("os-version", "5.5"), ("support-url", "mailto:cyrus-bugs+@andrew.cmu.edu"), ].into_iter() + .map(|(k, v)| (Cow::Borrowed(k), Cow::Borrowed(v))) .collect() ); } @@ -138,6 +136,7 @@ mod tests { ("os-version", "5.5"), ("support-url", "mailto:cyrus-bugs+@andrew.cmu.edu"), ].into_iter() + .map(|(k, v)| (Cow::Borrowed(k), Cow::Borrowed(v))) .collect() ); } diff --git a/vendor/imap-proto/src/parser/rfc3501/body.rs b/vendor/imap-proto/src/rfc3501/body.rs similarity index 50% rename from vendor/imap-proto/src/parser/rfc3501/body.rs rename to vendor/imap-proto/src/rfc3501/body.rs index 8992302..0d50176 100644 --- a/vendor/imap-proto/src/parser/rfc3501/body.rs +++ b/vendor/imap-proto/src/rfc3501/body.rs @@ -4,15 +4,16 @@ use nom::{ character::streaming::char, combinator::{map, opt}, multi::many0, - sequence::{delimited, preceded, tuple}, - IResult, + sequence::{delimited, preceded}, + IResult, Parser, }; use std::borrow::Cow; -use crate::{parser::core::*, types::*}; +use crate::core::*; +use crate::rfc3501::AttributeValue; pub fn section_part(i: &[u8]) -> IResult<&[u8], Vec> { - let (i, (part, mut rest)) = tuple((number, many0(preceded(char('.'), number))))(i)?; + let (i, (part, mut rest)) = (number, many0(preceded(char('.'), number))).parse(i)?; rest.insert(0, part); Ok((i, rest)) } @@ -20,53 +21,77 @@ pub fn section_part(i: &[u8]) -> IResult<&[u8], Vec> { pub fn section_msgtext(i: &[u8]) -> IResult<&[u8], MessageSection> { alt(( map( - tuple(( + ( tag_no_case("HEADER.FIELDS"), opt(tag_no_case(".NOT")), tag(" "), parenthesized_list(astring), - )), + ), |_| MessageSection::Header, ), map(tag_no_case("HEADER"), |_| MessageSection::Header), map(tag_no_case("TEXT"), |_| MessageSection::Text), - ))(i) + )) + .parse(i) } -pub fn section_text(i: &[u8]) -> IResult<&[u8], MessageSection> { - alt(( - section_msgtext, - map(tag_no_case("MIME"), |_| MessageSection::Mime), - ))(i) +#[derive(Debug, Eq, PartialEq)] +pub enum MessageSection { + Header, + Mime, + Text, } -pub fn section_spec(i: &[u8]) -> IResult<&[u8], SectionPath> { - alt(( - map(section_msgtext, SectionPath::Full), - map( - tuple((section_part, opt(preceded(char('.'), section_text)))), - |(part, text)| SectionPath::Part(part, text), - ), - ))(i) +impl MessageSection { + pub fn parse(i: &[u8]) -> IResult<&[u8], Self> { + alt(( + section_msgtext, + map(tag_no_case("MIME"), |_| MessageSection::Mime), + )) + .parse(i) + } +} + +#[derive(Debug, Eq, PartialEq)] +pub enum SectionPath { + Full(MessageSection), + Part(Vec, Option), +} + +impl SectionPath { + pub fn parse(i: &[u8]) -> IResult<&[u8], Self> { + alt(( + map(section_msgtext, SectionPath::Full), + map( + ( + section_part, + opt(preceded(char('.'), MessageSection::parse)), + ), + |(part, text)| SectionPath::Part(part, text), + ), + )) + .parse(i) + } } pub fn section(i: &[u8]) -> IResult<&[u8], Option> { - delimited(char('['), opt(section_spec), char(']'))(i) + delimited(char('['), opt(SectionPath::parse), char(']')).parse(i) } pub fn msg_att_body_section(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { map( - tuple(( + ( tag_no_case("BODY"), section, opt(delimited(char('<'), number, char('>'))), tag(" "), nstring, - )), + ), |(_, section, index, _, data)| AttributeValue::BodySection { section, index, data: data.map(Cow::Borrowed), }, - )(i) + ) + .parse(i) } diff --git a/vendor/imap-proto/src/rfc3501/body_structure.rs b/vendor/imap-proto/src/rfc3501/body_structure.rs new file mode 100644 index 0000000..6d1ce42 --- /dev/null +++ b/vendor/imap-proto/src/rfc3501/body_structure.rs @@ -0,0 +1,847 @@ +use nom::{ + branch::alt, + bytes::streaming::{tag, tag_no_case}, + character::streaming::char, + combinator::{map, opt}, + multi::many1, + sequence::{delimited, preceded}, + IResult, Parser, +}; +use std::borrow::Cow; + +use crate::{ + core::*, + rfc3501::{to_owned_cow, AttributeValue, Envelope}, +}; + +pub struct BodyFields<'a> { + pub param: BodyParams<'a>, + pub id: Option>, + pub description: Option>, + pub transfer_encoding: ContentEncoding<'a>, + pub octets: u32, +} + +impl<'a> BodyFields<'a> { + // body-fields = body-fld-param SP body-fld-id SP body-fld-desc SP + // body-fld-enc SP body-fld-octets + fn parse(i: &'a [u8]) -> IResult<&'a [u8], Self> { + let (i, (param, _, id, _, description, _, transfer_encoding, _, octets)) = ( + body_param, + tag(" "), + // body id seems to refer to the Message-ID or possibly Content-ID header, which + // by the definition in RFC 2822 seems to resolve to all ASCII characters (through + // a large amount of indirection which I did not have the patience to fully explore) + nstring_utf8, + tag(" "), + // Per https://tools.ietf.org/html/rfc2045#section-8, description should be all ASCII + nstring_utf8, + tag(" "), + ContentEncoding::parse, + tag(" "), + number, + ) + .parse(i)?; + Ok(( + i, + BodyFields { + param, + id, + description, + transfer_encoding, + octets, + }, + )) + } + + pub fn into_owned(self) -> BodyFields<'static> { + BodyFields { + param: body_param_owned(self.param), + id: self.id.map(to_owned_cow), + description: self.description.map(to_owned_cow), + transfer_encoding: self.transfer_encoding.into_owned(), + octets: self.octets, + } + } +} + +pub struct BodyExt1Part<'a> { + pub md5: Option>, + pub disposition: Option>, + pub language: Option>>, + pub location: Option>, + pub extension: Option>, +} + +impl<'a> BodyExt1Part<'a> { + // body-ext-1part = body-fld-md5 [SP body-fld-dsp [SP body-fld-lang + // [SP body-fld-loc *(SP body-extension)]]] + // ; MUST NOT be returned on non-extensible + // ; "BODY" fetch + fn parse(i: &'a [u8]) -> IResult<&'a [u8], Self> { + let (i, (md5, disposition, language, location, extension)) = ( + // Per RFC 1864, MD5 values are base64-encoded + opt_opt(preceded(tag(" "), nstring_utf8)), + opt_opt(preceded(tag(" "), body_disposition)), + opt_opt(preceded(tag(" "), body_lang)), + // Location appears to reference a URL, which by RFC 1738 (section 2.2) should be ASCII + opt_opt(preceded(tag(" "), nstring_utf8)), + opt(preceded(tag(" "), BodyExtension::parse)), + ) + .parse(i)?; + Ok(( + i, + BodyExt1Part { + md5, + disposition, + language, + location, + extension, + }, + )) + } + + pub fn into_owned(self) -> BodyExt1Part<'static> { + BodyExt1Part { + md5: self.md5.map(to_owned_cow), + disposition: self.disposition.map(|v| v.into_owned()), + language: self + .language + .map(|v| v.into_iter().map(to_owned_cow).collect()), + location: self.location.map(to_owned_cow), + extension: self.extension.map(|v| v.into_owned()), + } + } +} + +pub struct BodyExtMPart<'a> { + pub param: BodyParams<'a>, + pub disposition: Option>, + pub language: Option>>, + pub location: Option>, + pub extension: Option>, +} + +impl<'a> BodyExtMPart<'a> { + // body-ext-mpart = body-fld-param [SP body-fld-dsp [SP body-fld-lang + // [SP body-fld-loc *(SP body-extension)]]] + // ; MUST NOT be returned on non-extensible + // ; "BODY" fetch + fn parse(i: &'a [u8]) -> IResult<&'a [u8], Self> { + let (i, (param, disposition, language, location, extension)) = ( + opt_opt(preceded(tag(" "), body_param)), + opt_opt(preceded(tag(" "), body_disposition)), + opt_opt(preceded(tag(" "), body_lang)), + // Location appears to reference a URL, which by RFC 1738 (section 2.2) should be ASCII + opt_opt(preceded(tag(" "), nstring_utf8)), + opt(preceded(tag(" "), BodyExtension::parse)), + ) + .parse(i)?; + Ok(( + i, + BodyExtMPart { + param, + disposition, + language, + location, + extension, + }, + )) + } + + pub fn into_owned(self) -> BodyExtMPart<'static> { + BodyExtMPart { + param: body_param_owned(self.param), + disposition: self.disposition.map(|v| v.into_owned()), + language: self + .language + .map(|v| v.into_iter().map(to_owned_cow).collect()), + location: self.location.map(to_owned_cow), + extension: self.extension.map(|v| v.into_owned()), + } + } +} + +#[derive(Debug, Eq, PartialEq)] +pub enum ContentEncoding<'a> { + SevenBit, + EightBit, + Binary, + Base64, + QuotedPrintable, + Other(Cow<'a, str>), +} + +impl<'a> ContentEncoding<'a> { + // RFC 3501 defines `body-fld-enc` as a string, never NIL, but some servers send + // NIL for a part with no Content-Transfer-Encoding header (seen on Apple iCloud; + // imap-codec records the same from maddy). RFC 2045 s6.1 defaults that to 7bit. + fn parse(i: &'a [u8]) -> IResult<&'a [u8], Self> { + alt(( + map(nil, |_| ContentEncoding::SevenBit), + delimited( + char('"'), + alt(( + map(tag_no_case("7BIT"), |_| ContentEncoding::SevenBit), + map(tag_no_case("8BIT"), |_| ContentEncoding::EightBit), + map(tag_no_case("BINARY"), |_| ContentEncoding::Binary), + map(tag_no_case("BASE64"), |_| ContentEncoding::Base64), + map(tag_no_case("QUOTED-PRINTABLE"), |_| { + ContentEncoding::QuotedPrintable + }), + )), + char('"'), + ), + map(string_utf8, ContentEncoding::Other), + )) + .parse(i) + } + + pub fn into_owned(self) -> ContentEncoding<'static> { + match self { + ContentEncoding::SevenBit => ContentEncoding::SevenBit, + ContentEncoding::EightBit => ContentEncoding::EightBit, + ContentEncoding::Binary => ContentEncoding::Binary, + ContentEncoding::Base64 => ContentEncoding::Base64, + ContentEncoding::QuotedPrintable => ContentEncoding::QuotedPrintable, + ContentEncoding::Other(v) => ContentEncoding::Other(to_owned_cow(v)), + } + } +} + +fn body_lang(i: &[u8]) -> IResult<&[u8], Option>>> { + alt(( + // body language seems to refer to RFC 3066 language tags, which should be ASCII-only + map(nstring_utf8, |v| v.map(|s| vec![s])), + map(parenthesized_nonempty_list(string_utf8), Option::from), + )) + .parse(i) +} + +fn body_param(i: &[u8]) -> IResult<&[u8], BodyParams<'_>> { + alt(( + map(nil, |_| None), + map( + parenthesized_nonempty_list(map( + (string_utf8, tag(" "), string_utf8), + |(key, _, val)| (key, val), + )), + Option::from, + ), + )) + .parse(i) +} + +#[derive(Debug, Eq, PartialEq)] +pub enum BodyExtension<'a> { + Num(u32), + Str(Option>), + List(Vec>), +} + +impl<'a> BodyExtension<'a> { + fn parse(i: &'a [u8]) -> IResult<&'a [u8], Self> { + alt(( + map(number, BodyExtension::Num), + // Cannot find documentation on character encoding for body extension values. + // So far, assuming UTF-8 seems fine, please report if you run into issues here. + map(nstring_utf8, BodyExtension::Str), + map( + parenthesized_nonempty_list(BodyExtension::parse), + BodyExtension::List, + ), + )) + .parse(i) + } + + pub fn into_owned(self) -> BodyExtension<'static> { + match self { + BodyExtension::Num(v) => BodyExtension::Num(v), + BodyExtension::Str(v) => BodyExtension::Str(v.map(to_owned_cow)), + BodyExtension::List(v) => { + BodyExtension::List(v.into_iter().map(|v| v.into_owned()).collect()) + } + } + } +} + +fn body_disposition(i: &[u8]) -> IResult<&[u8], Option>> { + alt(( + map(nil, |_| None), + paren_delimited(map( + (string_utf8, tag(" "), body_param), + |(ty, _, params)| Some(ContentDisposition { ty, params }), + )), + )) + .parse(i) +} + +fn body_type_basic(i: &[u8]) -> IResult<&[u8], BodyStructure<'_>> { + map( + ( + string_utf8, + tag(" "), + string_utf8, + tag(" "), + BodyFields::parse, + BodyExt1Part::parse, + ), + |(ty, _, subtype, _, fields, ext)| BodyStructure::Basic { + common: BodyContentCommon { + ty: ContentType { + ty, + subtype, + params: fields.param, + }, + disposition: ext.disposition, + language: ext.language, + location: ext.location, + }, + other: BodyContentSinglePart { + id: fields.id, + md5: ext.md5, + octets: fields.octets, + description: fields.description, + transfer_encoding: fields.transfer_encoding, + }, + extension: ext.extension, + }, + ) + .parse(i) +} + +fn body_type_text(i: &[u8]) -> IResult<&[u8], BodyStructure<'_>> { + map( + ( + tag_no_case("\"TEXT\""), + tag(" "), + string_utf8, + tag(" "), + BodyFields::parse, + tag(" "), + number, + BodyExt1Part::parse, + ), + |(_, _, subtype, _, fields, _, lines, ext)| BodyStructure::Text { + common: BodyContentCommon { + ty: ContentType { + ty: Cow::Borrowed("TEXT"), + subtype, + params: fields.param, + }, + disposition: ext.disposition, + language: ext.language, + location: ext.location, + }, + other: BodyContentSinglePart { + id: fields.id, + md5: ext.md5, + octets: fields.octets, + description: fields.description, + transfer_encoding: fields.transfer_encoding, + }, + lines, + extension: ext.extension, + }, + ) + .parse(i) +} + +fn body_type_message(i: &[u8]) -> IResult<&[u8], BodyStructure<'_>> { + map( + ( + tag_no_case("\"MESSAGE\" \"RFC822\""), + tag(" "), + BodyFields::parse, + tag(" "), + Envelope::parse, + tag(" "), + BodyStructure::parse, + tag(" "), + number, + BodyExt1Part::parse, + ), + |(_, _, fields, _, envelope, _, body, _, lines, ext)| BodyStructure::Message { + common: BodyContentCommon { + ty: ContentType { + ty: Cow::Borrowed("MESSAGE"), + subtype: Cow::Borrowed("RFC822"), + params: fields.param, + }, + disposition: ext.disposition, + language: ext.language, + location: ext.location, + }, + other: BodyContentSinglePart { + id: fields.id, + md5: ext.md5, + octets: fields.octets, + description: fields.description, + transfer_encoding: fields.transfer_encoding, + }, + envelope, + body: Box::new(body), + lines, + extension: ext.extension, + }, + ) + .parse(i) +} + +fn body_type_multipart(i: &[u8]) -> IResult<&[u8], BodyStructure<'_>> { + map( + ( + many1(BodyStructure::parse), + tag(" "), + string_utf8, + BodyExtMPart::parse, + ), + |(bodies, _, subtype, ext)| BodyStructure::Multipart { + common: BodyContentCommon { + ty: ContentType { + ty: Cow::Borrowed("MULTIPART"), + subtype, + params: ext.param, + }, + disposition: ext.disposition, + language: ext.language, + location: ext.location, + }, + bodies, + extension: ext.extension, + }, + ) + .parse(i) +} + +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Eq, PartialEq)] +pub enum BodyStructure<'a> { + Basic { + common: BodyContentCommon<'a>, + other: BodyContentSinglePart<'a>, + extension: Option>, + }, + Text { + common: BodyContentCommon<'a>, + other: BodyContentSinglePart<'a>, + lines: u32, + extension: Option>, + }, + Message { + common: BodyContentCommon<'a>, + other: BodyContentSinglePart<'a>, + envelope: Envelope<'a>, + body: Box>, + lines: u32, + extension: Option>, + }, + Multipart { + common: BodyContentCommon<'a>, + bodies: Vec>, + extension: Option>, + }, +} + +impl<'a> BodyStructure<'a> { + pub(crate) fn parse(i: &'a [u8]) -> IResult<&'a [u8], Self> { + paren_delimited(alt(( + body_type_text, + body_type_message, + body_type_basic, + body_type_multipart, + ))) + .parse(i) + } + + pub fn into_owned(self) -> BodyStructure<'static> { + match self { + BodyStructure::Basic { + common, + other, + extension, + } => BodyStructure::Basic { + common: common.into_owned(), + other: other.into_owned(), + extension: extension.map(|v| v.into_owned()), + }, + BodyStructure::Text { + common, + other, + lines, + extension, + } => BodyStructure::Text { + common: common.into_owned(), + other: other.into_owned(), + lines, + extension: extension.map(|v| v.into_owned()), + }, + BodyStructure::Message { + common, + other, + envelope, + body, + lines, + extension, + } => BodyStructure::Message { + common: common.into_owned(), + other: other.into_owned(), + envelope: envelope.into_owned(), + body: Box::new(body.into_owned()), + lines, + extension: extension.map(|v| v.into_owned()), + }, + BodyStructure::Multipart { + common, + bodies, + extension, + } => BodyStructure::Multipart { + common: common.into_owned(), + bodies: bodies.into_iter().map(|v| v.into_owned()).collect(), + extension: extension.map(|v| v.into_owned()), + }, + } + } +} + +#[derive(Debug, Eq, PartialEq)] +pub struct BodyContentCommon<'a> { + pub ty: ContentType<'a>, + pub disposition: Option>, + pub language: Option>>, + pub location: Option>, +} + +impl<'a> BodyContentCommon<'a> { + pub fn into_owned(self) -> BodyContentCommon<'static> { + BodyContentCommon { + ty: self.ty.into_owned(), + disposition: self.disposition.map(|v| v.into_owned()), + language: self + .language + .map(|v| v.into_iter().map(to_owned_cow).collect()), + location: self.location.map(to_owned_cow), + } + } +} + +#[derive(Debug, Eq, PartialEq)] +pub struct BodyContentSinglePart<'a> { + pub id: Option>, + pub md5: Option>, + pub description: Option>, + pub transfer_encoding: ContentEncoding<'a>, + pub octets: u32, +} + +impl<'a> BodyContentSinglePart<'a> { + pub fn into_owned(self) -> BodyContentSinglePart<'static> { + BodyContentSinglePart { + id: self.id.map(to_owned_cow), + md5: self.md5.map(to_owned_cow), + description: self.description.map(to_owned_cow), + transfer_encoding: self.transfer_encoding.into_owned(), + octets: self.octets, + } + } +} + +#[derive(Debug, Eq, PartialEq)] +pub struct ContentType<'a> { + pub ty: Cow<'a, str>, + pub subtype: Cow<'a, str>, + pub params: BodyParams<'a>, +} + +impl<'a> ContentType<'a> { + pub fn into_owned(self) -> ContentType<'static> { + ContentType { + ty: to_owned_cow(self.ty), + subtype: to_owned_cow(self.subtype), + params: body_param_owned(self.params), + } + } +} + +#[derive(Debug, Eq, PartialEq)] +pub struct ContentDisposition<'a> { + pub ty: Cow<'a, str>, + pub params: BodyParams<'a>, +} + +impl<'a> ContentDisposition<'a> { + pub fn into_owned(self) -> ContentDisposition<'static> { + ContentDisposition { + ty: to_owned_cow(self.ty), + params: body_param_owned(self.params), + } + } +} + +pub type BodyParams<'a> = Option, Cow<'a, str>)>>; + +pub(crate) fn body_param_owned(v: BodyParams<'_>) -> BodyParams<'static> { + v.map(|v| { + v.into_iter() + .map(|(k, v)| (to_owned_cow(k), to_owned_cow(v))) + .collect() + }) +} + +pub(crate) fn msg_att_body_structure(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { + map( + (tag_no_case("BODYSTRUCTURE "), BodyStructure::parse), + |(_, body)| AttributeValue::BodyStructure(body), + ) + .parse(i) +} + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + + const EMPTY: &[u8] = &[]; + + // body-fld-param SP body-fld-id SP body-fld-desc SP body-fld-enc SP body-fld-octets + const BODY_FIELDS: &str = r#"("foo" "bar") "id" "desc" "7BIT" 1337"#; + const BODY_FIELD_PARAM_PAIR: (Cow<'_, str>, Cow<'_, str>) = + (Cow::Borrowed("foo"), Cow::Borrowed("bar")); + const BODY_FIELD_ID: Option> = Some(Cow::Borrowed("id")); + const BODY_FIELD_DESC: Option> = Some(Cow::Borrowed("desc")); + const BODY_FIELD_ENC: ContentEncoding = ContentEncoding::SevenBit; + const BODY_FIELD_OCTETS: u32 = 1337; + + fn mock_body_text() -> (String, BodyStructure<'static>) { + ( + format!(r#"("TEXT" "PLAIN" {BODY_FIELDS} 42)"#), + BodyStructure::Text { + common: BodyContentCommon { + ty: ContentType { + ty: Cow::Borrowed("TEXT"), + subtype: Cow::Borrowed("PLAIN"), + params: Some(vec![BODY_FIELD_PARAM_PAIR]), + }, + disposition: None, + language: None, + location: None, + }, + other: BodyContentSinglePart { + md5: None, + transfer_encoding: BODY_FIELD_ENC, + octets: BODY_FIELD_OCTETS, + id: BODY_FIELD_ID, + description: BODY_FIELD_DESC, + }, + lines: 42, + extension: None, + }, + ) + } + + #[test] + fn test_body_param_data() { + assert_matches!(body_param(br#"NIL"#), Ok((EMPTY, None))); + + assert_matches!( + body_param(br#"("foo" "bar")"#), + Ok((EMPTY, Some(param))) => { + assert_eq!(param, vec![(Cow::Borrowed("foo"), Cow::Borrowed("bar"))]); + } + ); + } + + #[test] + fn test_body_lang_data() { + assert_matches!( + body_lang(br#""bob""#), + Ok((EMPTY, Some(langs))) => { + assert_eq!(langs, vec!["bob"]); + } + ); + + assert_matches!( + body_lang(br#"("one" "two")"#), + Ok((EMPTY, Some(langs))) => { + assert_eq!(langs, vec!["one", "two"]); + } + ); + + assert_matches!(body_lang(br#"NIL"#), Ok((EMPTY, None))); + } + + #[test] + fn test_body_extension_data() { + assert_matches!( + BodyExtension::parse(br#""blah""#), + Ok((EMPTY, BodyExtension::Str(Some(Cow::Borrowed("blah"))))) + ); + + assert_matches!( + BodyExtension::parse(br#"NIL"#), + Ok((EMPTY, BodyExtension::Str(None))) + ); + + assert_matches!( + BodyExtension::parse(br#"("hello")"#), + Ok((EMPTY, BodyExtension::List(list))) => { + assert_eq!(list, vec![BodyExtension::Str(Some(Cow::Borrowed("hello")))]); + } + ); + + assert_matches!( + BodyExtension::parse(br#"(1337)"#), + Ok((EMPTY, BodyExtension::List(list))) => { + assert_eq!(list, vec![BodyExtension::Num(1337)]); + } + ); + } + + #[test] + fn test_body_disposition_data() { + assert_matches!(body_disposition(br#"NIL"#), Ok((EMPTY, None))); + + assert_matches!( + body_disposition(br#"("attachment" ("FILENAME" "pages.pdf"))"#), + Ok((EMPTY, Some(disposition))) => { + assert_eq!(disposition, ContentDisposition { + ty: Cow::Borrowed("attachment"), + params: Some(vec![ + (Cow::Borrowed("FILENAME"), Cow::Borrowed("pages.pdf")) + ]) + }); + } + ); + } + + // Apple iCloud sends NIL here, which the grammar does not allow. + #[test] + fn test_body_encoding_nil_is_seven_bit() { + assert_matches!( + ContentEncoding::parse(br"NIL"), + Ok((EMPTY, ContentEncoding::SevenBit)) + ); + } + + // The whole response has to survive it, not just the field. + #[test] + fn test_body_structure_multipart_with_nil_encoding() { + const BODY: &[u8] = br#"(("text" "plain" ("CHARSET" "UTF-8") NIL NIL NIL 1694 51 NIL NIL NIL NIL)("text" "html" ("CHARSET" "UTF-8") NIL NIL "quoted-printable" 5750 77 NIL NIL NIL NIL) "alternative" ("BOUNDARY" "94eb2c1235681e93cc0568edba59") NIL NIL NIL)"#; + + assert_matches!( + BodyStructure::parse(BODY), + Ok((EMPTY, BodyStructure::Multipart { bodies, .. })) => { + assert_eq!(bodies.len(), 2); + assert_matches!( + &bodies[0], + BodyStructure::Text { other, .. } => { + assert_eq!(other.transfer_encoding, ContentEncoding::SevenBit); + } + ); + } + ); + } + + #[test] + fn test_body_structure_text() { + let (body_str, body_struct) = mock_body_text(); + + assert_matches!( + BodyStructure::parse(body_str.as_bytes()), + Ok((_, text)) => { + assert_eq!(text, body_struct); + } + ); + } + + #[test] + fn test_body_structure_text_with_ext() { + let body_str = format!(r#"("TEXT" "PLAIN" {BODY_FIELDS} 42 NIL NIL NIL NIL)"#); + let (_, text_body_struct) = mock_body_text(); + + assert_matches!( + BodyStructure::parse(body_str.as_bytes()), + Ok((_, text)) => { + assert_eq!(text, text_body_struct) + } + ); + } + + #[test] + fn test_body_structure_basic() { + const BODY: &[u8] = br#"("APPLICATION" "PDF" ("NAME" "pages.pdf") NIL NIL "BASE64" 38838 NIL ("attachment" ("FILENAME" "pages.pdf")) NIL NIL)"#; + + assert_matches!( + BodyStructure::parse(BODY), + Ok((_, basic)) => { + assert_eq!(basic, BodyStructure::Basic { + common: BodyContentCommon { + ty: ContentType { + ty: Cow::Borrowed("APPLICATION"), + subtype: Cow::Borrowed("PDF"), + params: Some(vec![(Cow::Borrowed("NAME"), Cow::Borrowed("pages.pdf"))]) + }, + disposition: Some(ContentDisposition { + ty: Cow::Borrowed("attachment"), + params: Some(vec![(Cow::Borrowed("FILENAME"), Cow::Borrowed("pages.pdf"))]) + }), + language: None, + location: None, + }, + other: BodyContentSinglePart { + transfer_encoding: ContentEncoding::Base64, + octets: 38838, + id: None, + md5: None, + description: None, + }, + extension: None, + }) + } + ); + } + + #[test] + fn test_body_structure_message() { + let (text_body_str, _) = mock_body_text(); + let envelope_str = r#"("Wed, 17 Jul 1996 02:23:25 -0700 (PDT)" "IMAP4rev1 WG mtg summary and minutes" (("Terry Gray" NIL "gray" "cac.washington.edu")) (("Terry Gray" NIL "gray" "cac.washington.edu")) (("Terry Gray" NIL "gray" "cac.washington.edu")) ((NIL NIL "imap" "cac.washington.edu")) ((NIL NIL "minutes" "CNRI.Reston.VA.US") ("John Klensin" NIL "KLENSIN" "MIT.EDU")) NIL NIL "")"#; + let body_str = + format!(r#"("MESSAGE" "RFC822" {BODY_FIELDS} {envelope_str} {text_body_str} 42)"#); + + assert_matches!( + BodyStructure::parse(body_str.as_bytes()), + Ok((_, BodyStructure::Message { .. })) + ); + } + + #[test] + fn test_body_structure_multipart() { + let (text_body_str1, text_body_struct1) = mock_body_text(); + let (text_body_str2, text_body_struct2) = mock_body_text(); + let body_str = + format!(r#"({text_body_str1}{text_body_str2} "ALTERNATIVE" NIL NIL NIL NIL)"#); + + assert_matches!( + BodyStructure::parse(body_str.as_bytes()), + Ok((_, multipart)) => { + assert_eq!(multipart, BodyStructure::Multipart { + common: BodyContentCommon { + ty: ContentType { + ty: Cow::Borrowed("MULTIPART"), + subtype: Cow::Borrowed("ALTERNATIVE"), + params: None + }, + language: None, + location: None, + disposition: None, + }, + bodies: vec![ + text_body_struct1, + text_body_struct2, + ], + extension: None + }); + } + ); + } +} diff --git a/vendor/imap-proto/src/rfc3501/mod.rs b/vendor/imap-proto/src/rfc3501/mod.rs new file mode 100644 index 0000000..4a3e29d --- /dev/null +++ b/vendor/imap-proto/src/rfc3501/mod.rs @@ -0,0 +1,1461 @@ +//! +//! +//! +//! INTERNET MESSAGE ACCESS PROTOCOL +//! + +use std::borrow::Cow; +use std::str::from_utf8; + +use nom::{ + branch::alt, + bytes::streaming::{tag, tag_no_case, take_while, take_while1}, + character::streaming::char, + combinator::{map, map_res, opt, recognize, value}, + multi::{many0, many1}, + sequence::{delimited, pair, preceded, terminated}, + IResult, Parser, +}; + +use crate::{ + core::*, rfc2087, rfc2971, rfc3501::body::*, rfc3501::body_structure::*, rfc4314, rfc4315, + rfc4315::UidSetMember, rfc4551, rfc5161, rfc5256, rfc5464, rfc5464::Metadata, rfc7162, + Response, +}; + +use super::gmail; + +pub mod body; +pub mod body_structure; + +fn is_tag_char(c: u8) -> bool { + c != b'+' && is_astring_char(c) +} + +fn status_ok(i: &[u8]) -> IResult<&[u8], Status> { + map(tag_no_case("OK"), |_s| Status::Ok).parse(i) +} +fn status_no(i: &[u8]) -> IResult<&[u8], Status> { + map(tag_no_case("NO"), |_s| Status::No).parse(i) +} +fn status_bad(i: &[u8]) -> IResult<&[u8], Status> { + map(tag_no_case("BAD"), |_s| Status::Bad).parse(i) +} +fn status_preauth(i: &[u8]) -> IResult<&[u8], Status> { + map(tag_no_case("PREAUTH"), |_s| Status::PreAuth).parse(i) +} +fn status_bye(i: &[u8]) -> IResult<&[u8], Status> { + map(tag_no_case("BYE"), |_s| Status::Bye).parse(i) +} + +#[derive(Debug, Eq, PartialEq)] +pub enum Status { + Ok, + No, + Bad, + PreAuth, + Bye, +} + +impl Status { + fn parse(i: &[u8]) -> IResult<&[u8], Self> { + alt((status_ok, status_no, status_bad, status_preauth, status_bye)).parse(i) + } +} + +pub(crate) fn mailbox(i: &[u8]) -> IResult<&[u8], Cow<'_, str>> { + map(astring_utf8, |s| { + if s.eq_ignore_ascii_case("INBOX") { + Cow::Borrowed("INBOX") + } else { + s + } + }) + .parse(i) +} + +fn flag_extension(i: &[u8]) -> IResult<&[u8], &str> { + map_res( + recognize(pair(tag("\\"), take_while(is_atom_char))), + from_utf8, + ) + .parse(i) +} + +pub(crate) fn flag(i: &[u8]) -> IResult<&[u8], &str> { + // Correct code is + // alt((flag_extension, atom))(i) + // + // Unfortunately, some unknown providers send the following response: + // * FLAGS (OIB-Seen-[Gmail]/All) + // + // As a workaround, ']' (resp-specials) is allowed here. + alt(( + flag_extension, + map_res(take_while1(is_astring_char), from_utf8), + )) + .parse(i) +} + +fn flag_list(i: &[u8]) -> IResult<&[u8], Vec>> { + // Correct code is + // parenthesized_list(flag)(i) + // + // Unfortunately, Zoho Mail Server (imap.zoho.com) sends the following response: + // * FLAGS (\Answered \Flagged \Deleted \Seen \Draft \*) + // + // As a workaround, "\*" is allowed here. + // + // Also, surgemail sends an additional space before the closing bracket: + // * FLAGS (\Answered \Flagged \Deleted \Draft \Seen $Forwarded ) + // + // As a workaround, optional spaces before the closing bracket are allowed. + parenthesized_list(map(flag_perm, Cow::Borrowed)).parse(i) +} + +fn flag_perm(i: &[u8]) -> IResult<&[u8], &str> { + alt((map_res(tag("\\*"), from_utf8), flag)).parse(i) +} + +fn resp_text_code_alert(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { + map(tag_no_case("ALERT"), |_| ResponseCode::Alert).parse(i) +} + +fn resp_text_code_badcharset(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { + map( + preceded( + tag_no_case("BADCHARSET"), + opt(preceded( + tag(" "), + parenthesized_nonempty_list(astring_utf8), + )), + ), + ResponseCode::BadCharset, + ) + .parse(i) +} + +fn resp_text_code_capability(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { + map(capability_data, ResponseCode::Capabilities).parse(i) +} + +fn resp_text_code_parse(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { + map(tag_no_case("PARSE"), |_| ResponseCode::Parse).parse(i) +} + +fn resp_text_code_permanent_flags(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { + map( + preceded( + tag_no_case("PERMANENTFLAGS "), + parenthesized_list(map(flag_perm, Cow::Borrowed)), + ), + ResponseCode::PermanentFlags, + ) + .parse(i) +} + +fn resp_text_code_read_only(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { + map(tag_no_case("READ-ONLY"), |_| ResponseCode::ReadOnly).parse(i) +} + +fn resp_text_code_read_write(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { + map(tag_no_case("READ-WRITE"), |_| ResponseCode::ReadWrite).parse(i) +} + +fn resp_text_code_try_create(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { + map(tag_no_case("TRYCREATE"), |_| ResponseCode::TryCreate).parse(i) +} + +fn resp_text_code_uid_validity(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { + map( + preceded(tag_no_case("UIDVALIDITY "), number), + ResponseCode::UidValidity, + ) + .parse(i) +} + +fn resp_text_code_uid_next(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { + map( + preceded(tag_no_case("UIDNEXT "), number), + ResponseCode::UidNext, + ) + .parse(i) +} + +fn resp_text_code_unseen(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { + map( + preceded(tag_no_case("UNSEEN "), number), + ResponseCode::Unseen, + ) + .parse(i) +} + +#[derive(Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ResponseCode<'a> { + Alert, + BadCharset(Option>>), + Capabilities(Vec>), + HighestModSeq(u64), // RFC 4551, section 3.1.1 + Parse, + PermanentFlags(Vec>), + ReadOnly, + ReadWrite, + TryCreate, + UidNext(u32), + UidValidity(u32), + Unseen(u32), + AppendUid(u32, Vec), + CopyUid(u32, Vec, Vec), + UidNotSticky, + MetadataLongEntries(u64), // RFC 5464, section 4.2.1 + MetadataMaxSize(u64), // RFC 5464, section 4.3 + MetadataTooMany, // RFC 5464, section 4.3 + MetadataNoPrivate, // RFC 5464, section 4.3 +} + +impl<'a> ResponseCode<'a> { + fn parse(i: &'a [u8]) -> IResult<&'a [u8], Self> { + // Per the spec, the closing tag should be "] ". + // See `Outcome::parse` for more on why this is done differently. + delimited( + tag("["), + alt(( + resp_text_code_alert, + resp_text_code_badcharset, + resp_text_code_capability, + resp_text_code_parse, + resp_text_code_permanent_flags, + resp_text_code_uid_validity, + resp_text_code_uid_next, + resp_text_code_unseen, + resp_text_code_read_only, + resp_text_code_read_write, + resp_text_code_try_create, + rfc4551::resp_text_code_highest_mod_seq, + rfc4315::resp_text_code_append_uid, + rfc4315::resp_text_code_copy_uid, + rfc4315::resp_text_code_uid_not_sticky, + rfc5464::resp_text_code_metadata_long_entries, + rfc5464::resp_text_code_metadata_max_size, + rfc5464::resp_text_code_metadata_too_many, + rfc5464::resp_text_code_metadata_no_private, + )), + tag("]"), + ) + .parse(i) + } + + pub fn into_owned(self) -> ResponseCode<'static> { + match self { + ResponseCode::Alert => ResponseCode::Alert, + ResponseCode::BadCharset(v) => { + ResponseCode::BadCharset(v.map(|vs| vs.into_iter().map(to_owned_cow).collect())) + } + ResponseCode::Capabilities(v) => { + ResponseCode::Capabilities(v.into_iter().map(Capability::into_owned).collect()) + } + ResponseCode::HighestModSeq(v) => ResponseCode::HighestModSeq(v), + ResponseCode::Parse => ResponseCode::Parse, + ResponseCode::PermanentFlags(v) => { + ResponseCode::PermanentFlags(v.into_iter().map(to_owned_cow).collect()) + } + ResponseCode::ReadOnly => ResponseCode::ReadOnly, + ResponseCode::ReadWrite => ResponseCode::ReadWrite, + ResponseCode::TryCreate => ResponseCode::TryCreate, + ResponseCode::UidNext(v) => ResponseCode::UidNext(v), + ResponseCode::UidValidity(v) => ResponseCode::UidValidity(v), + ResponseCode::Unseen(v) => ResponseCode::Unseen(v), + ResponseCode::AppendUid(a, b) => ResponseCode::AppendUid(a, b), + ResponseCode::CopyUid(a, b, c) => ResponseCode::CopyUid(a, b, c), + ResponseCode::UidNotSticky => ResponseCode::UidNotSticky, + ResponseCode::MetadataLongEntries(v) => ResponseCode::MetadataLongEntries(v), + ResponseCode::MetadataMaxSize(v) => ResponseCode::MetadataMaxSize(v), + ResponseCode::MetadataTooMany => ResponseCode::MetadataTooMany, + ResponseCode::MetadataNoPrivate => ResponseCode::MetadataNoPrivate, + } + } +} + +#[derive(Debug, Eq, PartialEq, Hash)] +pub enum Capability<'a> { + Imap4rev1, + Auth(Cow<'a, str>), + Atom(Cow<'a, str>), +} + +impl<'a> Capability<'a> { + fn parse(i: &'a [u8]) -> IResult<&'a [u8], Self> { + alt(( + map(tag_no_case("IMAP4rev1"), |_| Capability::Imap4rev1), + map( + map(preceded(tag_no_case("AUTH="), atom), Cow::Borrowed), + Capability::Auth, + ), + map(map(atom, Cow::Borrowed), Capability::Atom), + )) + .parse(i) + } + + pub fn into_owned(self) -> Capability<'static> { + match self { + Capability::Imap4rev1 => Capability::Imap4rev1, + Capability::Auth(v) => Capability::Auth(to_owned_cow(v)), + Capability::Atom(v) => Capability::Atom(to_owned_cow(v)), + } + } +} + +fn ensure_capabilities_contains_imap4rev( + capabilities: Vec>, +) -> Result>, ()> { + if capabilities.contains(&Capability::Imap4rev1) { + Ok(capabilities) + } else { + Err(()) + } +} + +fn capability_data(i: &[u8]) -> IResult<&[u8], Vec>> { + map_res( + preceded( + tag_no_case("CAPABILITY"), + many0(preceded(char(' '), Capability::parse)), + ), + ensure_capabilities_contains_imap4rev, + ) + .parse(i) +} + +fn mailbox_data_search(i: &[u8]) -> IResult<&[u8], MailboxDatum<'_>> { + map( + // Technically, trailing whitespace is not allowed here, but multiple + // email servers in the wild seem to have it anyway (see #34, #108). + terminated( + preceded(tag_no_case("SEARCH"), many0(preceded(tag(" "), number))), + opt(tag(" ")), + ), + MailboxDatum::Search, + ) + .parse(i) +} + +fn mailbox_data_flags(i: &[u8]) -> IResult<&[u8], MailboxDatum<'_>> { + map( + preceded(tag_no_case("FLAGS "), flag_list), + MailboxDatum::Flags, + ) + .parse(i) +} + +fn mailbox_data_exists(i: &[u8]) -> IResult<&[u8], MailboxDatum<'_>> { + map( + terminated(number, tag_no_case(" EXISTS")), + MailboxDatum::Exists, + ) + .parse(i) +} + +/// The name attributes are returned as part of a LIST response described in +/// [RFC 3501 section 7.2.2](https://tools.ietf.org/html/rfc3501#section-7.2.2). +/// +/// This enumeration additional includes values from the extension Special-Use +/// Mailboxes [RFC 6154 section 2](https://tools.ietf.org/html/rfc6154#section-2). +#[derive(Debug, Eq, PartialEq, Clone)] +#[non_exhaustive] +pub enum NameAttribute<'a> { + /// From [RFC 3501 section 7.2.2](https://tools.ietf.org/html/rfc3501#section-7.2.2): + /// + /// > It is not possible for any child levels of hierarchy to exist + /// > under this name; no child levels exist now and none can be + /// > created in the future. + NoInferiors, + /// From [RFC 3501 section 7.2.2](https://tools.ietf.org/html/rfc3501#section-7.2.2): + /// + /// > It is not possible to use this name as a selectable mailbox. + NoSelect, + /// From [RFC 3501 section 7.2.2](https://tools.ietf.org/html/rfc3501#section-7.2.2): + /// + /// > The mailbox has been marked "interesting" by the server; the + /// > mailbox probably contains messages that have been added since + /// > the last time the mailbox was selected. + Marked, + /// From [RFC 3501 section 7.2.2](https://tools.ietf.org/html/rfc3501#section-7.2.2): + /// + /// > The mailbox does not contain any additional messages since the + /// > last time the mailbox was selected. + Unmarked, + /// From [RFC 6154 section 2](https://tools.ietf.org/html/rfc6154#section-2): + /// + /// > This mailbox presents all messages in the user's message store. + /// > Implementations MAY omit some messages, such as, perhaps, those + /// > in \Trash and \Junk. When this special use is supported, it is + /// > almost certain to represent a virtual mailbox. + All, + /// From [RFC 6154 section 2](https://tools.ietf.org/html/rfc6154#section-2): + /// + /// > This mailbox is used to archive messages. The meaning of an + /// > "archival" mailbox is server-dependent; typically, it will be + /// > used to get messages out of the inbox, or otherwise keep them + /// > out of the user's way, while still making them accessible. + Archive, + /// From [RFC 6154 section 2](https://tools.ietf.org/html/rfc6154#section-2): + /// + /// > This mailbox is used to hold draft messages -- typically, + /// > messages that are being composed but have not yet been sent. In + /// > some server implementations, this might be a virtual mailbox, + /// > containing messages from other mailboxes that are marked with + /// > the "\Draft" message flag. Alternatively, this might just be + /// > advice that a client put drafts here. + Drafts, + /// From [RFC 6154 section 2](https://tools.ietf.org/html/rfc6154#section-2): + /// + /// > This mailbox presents all messages marked in some way as + /// > "important". When this special use is supported, it is likely + /// > to represent a virtual mailbox collecting messages (from other + /// > mailboxes) that are marked with the "\Flagged" message flag. + Flagged, + /// From [RFC 6154 section 2](https://tools.ietf.org/html/rfc6154#section-2): + /// + /// > This mailbox is where messages deemed to be junk mail are held. + /// > Some server implementations might put messages here + /// > automatically. Alternatively, this might just be advice to a + /// > client-side spam filter. + Junk, + /// From [RFC 6154 section 2](https://tools.ietf.org/html/rfc6154#section-2): + /// + /// > This mailbox is used to hold copies of messages that have been + /// > sent. Some server implementations might put messages here + /// > automatically. Alternatively, this might just be advice that a + /// > client save sent messages here. + Sent, + /// From [RFC 6154 section 2](https://tools.ietf.org/html/rfc6154#section-2) + /// + /// > This mailbox is used to hold messages that have been deleted or + /// > marked for deletion. In some server implementations, this might + /// > be a virtual mailbox, containing messages from other mailboxes + /// > that are marked with the "\Deleted" message flag. + /// > Alternatively, this might just be advice that a client that + /// > chooses not to use the IMAP "\Deleted" model should use this as + /// > its trash location. In server implementations that strictly + /// > expect the IMAP "\Deleted" model, this special use is likely not + /// > to be supported. + Trash, + /// A name attribute not defined in [RFC 3501 section 7.2.2](https://tools.ietf.org/html/rfc3501#section-7.2.2) + /// or any supported extension. + Extension(Cow<'a, str>), +} + +impl<'a> NameAttribute<'a> { + fn parse(i: &'a [u8]) -> IResult<&'a [u8], Self> { + alt(( + // RFC 3501 + value(NameAttribute::NoInferiors, tag_no_case("\\Noinferiors")), + value(NameAttribute::NoSelect, tag_no_case("\\Noselect")), + value(NameAttribute::Marked, tag_no_case("\\Marked")), + value(NameAttribute::Unmarked, tag_no_case("\\Unmarked")), + // RFC 6154 + value(NameAttribute::All, tag_no_case("\\All")), + value(NameAttribute::Archive, tag_no_case("\\Archive")), + value(NameAttribute::Drafts, tag_no_case("\\Drafts")), + value(NameAttribute::Flagged, tag_no_case("\\Flagged")), + value(NameAttribute::Junk, tag_no_case("\\Junk")), + value(NameAttribute::Sent, tag_no_case("\\Sent")), + value(NameAttribute::Trash, tag_no_case("\\Trash")), + // Extensions not supported by this crate + map( + map_res( + recognize(pair(tag("\\"), take_while(is_atom_char))), + from_utf8, + ), + |s| NameAttribute::Extension(Cow::Borrowed(s)), + ), + )) + .parse(i) + } + + pub fn into_owned(self) -> NameAttribute<'static> { + match self { + // RFC 3501 + NameAttribute::NoInferiors => NameAttribute::NoInferiors, + NameAttribute::NoSelect => NameAttribute::NoSelect, + NameAttribute::Marked => NameAttribute::Marked, + NameAttribute::Unmarked => NameAttribute::Unmarked, + // RFC 6154 + NameAttribute::All => NameAttribute::All, + NameAttribute::Archive => NameAttribute::Archive, + NameAttribute::Drafts => NameAttribute::Drafts, + NameAttribute::Flagged => NameAttribute::Flagged, + NameAttribute::Junk => NameAttribute::Junk, + NameAttribute::Sent => NameAttribute::Sent, + NameAttribute::Trash => NameAttribute::Trash, + // Extensions not supported by this crate + NameAttribute::Extension(s) => NameAttribute::Extension(to_owned_cow(s)), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MailboxListData<'a> { + pub name_attributes: Vec>, + pub delimiter: Option>, + pub name: Cow<'a, str>, +} + +impl<'a> MailboxListData<'a> { + fn parse(i: &'a [u8]) -> IResult<&'a [u8], Self> { + map( + ( + parenthesized_list(NameAttribute::parse), + tag(" "), + alt((map(quoted_utf8, Some), map(nil, |_| None))), + tag(" "), + mailbox, + ), + |(name_attributes, _, delimiter, _, name)| MailboxListData { + name_attributes, + delimiter, + name, + }, + ) + .parse(i) + } + + pub fn into_owned(self) -> MailboxListData<'static> { + MailboxListData { + name_attributes: self + .name_attributes + .into_iter() + .map(|named_attribute| named_attribute.into_owned()) + .collect(), + delimiter: self.delimiter.map(to_owned_cow), + name: to_owned_cow(self.name), + } + } +} + +fn mailbox_data_list(i: &[u8]) -> IResult<&[u8], MailboxDatum<'_>> { + map( + preceded(tag_no_case("LIST "), MailboxListData::parse), + MailboxDatum::List, + ) + .parse(i) +} + +fn mailbox_data_lsub(i: &[u8]) -> IResult<&[u8], MailboxDatum<'_>> { + map( + preceded(tag_no_case("LSUB "), MailboxListData::parse), + MailboxDatum::List, + ) + .parse(i) +} + +#[derive(Debug, Eq, PartialEq, Clone)] +#[non_exhaustive] +pub enum StatusAttribute { + HighestModSeq(u64), // RFC 4551 + Messages(u32), + Recent(u32), + UidNext(u32), + UidValidity(u32), + Unseen(u32), +} + +impl StatusAttribute { + // Unlike `status_att` in the RFC syntax, this includes the value, + // so that it can return a valid enum object instead of just a key. + fn parse(i: &[u8]) -> IResult<&[u8], Self> { + alt(( + rfc4551::status_att_val_highest_mod_seq, + map( + preceded(tag_no_case("MESSAGES "), number), + StatusAttribute::Messages, + ), + map( + preceded(tag_no_case("RECENT "), number), + StatusAttribute::Recent, + ), + map( + preceded(tag_no_case("UIDNEXT "), number), + StatusAttribute::UidNext, + ), + map( + preceded(tag_no_case("UIDVALIDITY "), number), + StatusAttribute::UidValidity, + ), + map( + preceded(tag_no_case("UNSEEN "), number), + StatusAttribute::Unseen, + ), + )) + .parse(i) + } +} + +fn status_att_list(i: &[u8]) -> IResult<&[u8], Vec> { + // RFC 3501 specifies that the list is non-empty in the formal grammar + // status-att-list = status-att SP number *(SP status-att SP number) + // but mail.163.com sends an empty list in STATUS response anyway. + parenthesized_list(StatusAttribute::parse).parse(i) +} + +fn mailbox_data_status(i: &[u8]) -> IResult<&[u8], MailboxDatum<'_>> { + map( + (tag_no_case("STATUS "), mailbox, tag(" "), status_att_list), + |(_, mailbox, _, status)| MailboxDatum::Status { mailbox, status }, + ) + .parse(i) +} + +fn mailbox_data_recent(i: &[u8]) -> IResult<&[u8], MailboxDatum<'_>> { + map( + terminated(number, tag_no_case(" RECENT")), + MailboxDatum::Recent, + ) + .parse(i) +} + +#[derive(Debug, Eq, PartialEq, Clone)] +#[non_exhaustive] +pub enum MailboxDatum<'a> { + Exists(u32), + Flags(Vec>), + List(MailboxListData<'a>), + Search(Vec), + Sort(Vec), + Status { + mailbox: Cow<'a, str>, + status: Vec, + }, + Recent(u32), + MetadataSolicited { + mailbox: Cow<'a, str>, + values: Vec, + }, + MetadataUnsolicited { + mailbox: Cow<'a, str>, + values: Vec>, + }, + GmailLabels(Vec>), + GmailMsgId(u64), + GmailThrId(u64), +} + +impl<'a> MailboxDatum<'a> { + fn parse(i: &'a [u8]) -> IResult<&'a [u8], Self> { + alt(( + mailbox_data_flags, + mailbox_data_exists, + mailbox_data_list, + mailbox_data_lsub, + mailbox_data_status, + mailbox_data_recent, + mailbox_data_search, + gmail::mailbox_data_gmail_labels, + gmail::mailbox_data_gmail_msgid, + gmail::mailbox_data_gmail_thrid, + rfc5256::mailbox_data_sort, + )) + .parse(i) + } + + pub fn into_owned(self) -> MailboxDatum<'static> { + match self { + MailboxDatum::Exists(seq) => MailboxDatum::Exists(seq), + MailboxDatum::Flags(flags) => { + MailboxDatum::Flags(flags.into_iter().map(to_owned_cow).collect()) + } + MailboxDatum::List(data) => MailboxDatum::List(data.into_owned()), + MailboxDatum::Search(seqs) => MailboxDatum::Search(seqs), + MailboxDatum::Sort(seqs) => MailboxDatum::Sort(seqs), + MailboxDatum::Status { mailbox, status } => MailboxDatum::Status { + mailbox: to_owned_cow(mailbox), + status, + }, + MailboxDatum::Recent(seq) => MailboxDatum::Recent(seq), + MailboxDatum::MetadataSolicited { mailbox, values } => { + MailboxDatum::MetadataSolicited { + mailbox: to_owned_cow(mailbox), + values, + } + } + MailboxDatum::MetadataUnsolicited { mailbox, values } => { + MailboxDatum::MetadataUnsolicited { + mailbox: to_owned_cow(mailbox), + values: values.into_iter().map(to_owned_cow).collect(), + } + } + MailboxDatum::GmailLabels(labels) => { + MailboxDatum::GmailLabels(labels.into_iter().map(to_owned_cow).collect()) + } + MailboxDatum::GmailMsgId(msgid) => MailboxDatum::GmailMsgId(msgid), + MailboxDatum::GmailThrId(thrid) => MailboxDatum::GmailThrId(thrid), + } + } +} + +#[derive(Debug, Eq, PartialEq)] +pub struct Address<'a> { + pub name: Option>, + pub adl: Option>, + pub mailbox: Option>, + pub host: Option>, +} + +impl<'a> Address<'a> { + // An address structure is a parenthesized list that describes an + // electronic mail address. + fn parse(i: &'a [u8]) -> IResult<&'a [u8], Self> { + paren_delimited(map( + ( + nstring, + tag(" "), + nstring, + tag(" "), + nstring, + tag(" "), + nstring, + ), + |(name, _, adl, _, mailbox, _, host)| Address { + name: name.map(Cow::Borrowed), + adl: adl.map(Cow::Borrowed), + mailbox: mailbox.map(Cow::Borrowed), + host: host.map(Cow::Borrowed), + }, + )) + .parse(i) + } + + pub fn into_owned(self) -> Address<'static> { + Address { + name: self.name.map(to_owned_cow), + adl: self.adl.map(to_owned_cow), + mailbox: self.mailbox.map(to_owned_cow), + host: self.host.map(to_owned_cow), + } + } +} + +fn opt_addresses(i: &[u8]) -> IResult<&[u8], Option>>> { + alt(( + map(nil, |_s| None), + map( + paren_delimited(many1(terminated(Address::parse, opt(char(' '))))), + Some, + ), + )) + .parse(i) +} + +/// An RFC 2822 envelope +/// +/// See for more details. +#[derive(Debug, Eq, PartialEq)] +pub struct Envelope<'a> { + pub date: Option>, + pub subject: Option>, + /// Author of the message; mailbox responsible for writing the message + pub from: Option>>, + /// Mailbox of the agent responsible for the message's transmission + pub sender: Option>>, + /// Mailbox that the author of the message suggests replies be sent to + pub reply_to: Option>>, + pub to: Option>>, + pub cc: Option>>, + pub bcc: Option>>, + pub in_reply_to: Option>, + pub message_id: Option>, +} + +impl<'a> Envelope<'a> { + // envelope = "(" env-date SP env-subject SP env-from SP + // env-sender SP env-reply-to SP env-to SP env-cc SP + // env-bcc SP env-in-reply-to SP env-message-id ")" + // + // env-bcc = "(" 1*address ")" / nil + // + // env-cc = "(" 1*address ")" / nil + // + // env-date = nstring + // + // env-from = "(" 1*address ")" / nil + // + // env-in-reply-to = nstring + // + // env-message-id = nstring + // + // env-reply-to = "(" 1*address ")" / nil + // + // env-sender = "(" 1*address ")" / nil + // + // env-subject = nstring + // + // env-to = "(" 1*address ")" / nil + pub(crate) fn parse(i: &'a [u8]) -> IResult<&'a [u8], Self> { + paren_delimited(map( + ( + nstring, + tag(" "), + nstring, + tag(" "), + opt_addresses, + tag(" "), + opt_addresses, + tag(" "), + opt_addresses, + tag(" "), + opt_addresses, + tag(" "), + opt_addresses, + tag(" "), + opt_addresses, + tag(" "), + nstring, + tag(" "), + nstring, + ), + |( + date, + _, + subject, + _, + from, + _, + sender, + _, + reply_to, + _, + to, + _, + cc, + _, + bcc, + _, + in_reply_to, + _, + message_id, + )| Envelope { + date: date.map(Cow::Borrowed), + subject: subject.map(Cow::Borrowed), + from, + sender, + reply_to, + to, + cc, + bcc, + in_reply_to: in_reply_to.map(Cow::Borrowed), + message_id: message_id.map(Cow::Borrowed), + }, + )) + .parse(i) + } + + pub fn into_owned(self) -> Envelope<'static> { + Envelope { + date: self.date.map(to_owned_cow), + subject: self.subject.map(to_owned_cow), + from: self + .from + .map(|v| v.into_iter().map(|v| v.into_owned()).collect()), + sender: self + .sender + .map(|v| v.into_iter().map(|v| v.into_owned()).collect()), + reply_to: self + .reply_to + .map(|v| v.into_iter().map(|v| v.into_owned()).collect()), + to: self + .to + .map(|v| v.into_iter().map(|v| v.into_owned()).collect()), + cc: self + .cc + .map(|v| v.into_iter().map(|v| v.into_owned()).collect()), + bcc: self + .bcc + .map(|v| v.into_iter().map(|v| v.into_owned()).collect()), + in_reply_to: self.in_reply_to.map(to_owned_cow), + message_id: self.message_id.map(to_owned_cow), + } + } +} + +fn msg_att_envelope(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { + map( + preceded(tag_no_case("ENVELOPE "), Envelope::parse), + |envelope| AttributeValue::Envelope(Box::new(envelope)), + ) + .parse(i) +} + +fn msg_att_internal_date(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { + map( + preceded(tag_no_case("INTERNALDATE "), nstring_utf8), + |date| AttributeValue::InternalDate(date.unwrap()), + ) + .parse(i) +} + +fn msg_att_flags(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { + map( + preceded(tag_no_case("FLAGS "), flag_list), + AttributeValue::Flags, + ) + .parse(i) +} + +fn msg_att_rfc822(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { + map(preceded(tag_no_case("RFC822 "), nstring), |v| { + AttributeValue::Rfc822(v.map(Cow::Borrowed)) + }) + .parse(i) +} + +fn msg_att_rfc822_header(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { + // extra space workaround for DavMail + map( + (tag_no_case("RFC822.HEADER "), opt(tag(" ")), nstring), + |(_, _, raw)| AttributeValue::Rfc822Header(raw.map(Cow::Borrowed)), + ) + .parse(i) +} + +fn msg_att_rfc822_size(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { + map( + preceded(tag_no_case("RFC822.SIZE "), number), + AttributeValue::Rfc822Size, + ) + .parse(i) +} + +fn msg_att_rfc822_text(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { + map(preceded(tag_no_case("RFC822.TEXT "), nstring), |v| { + AttributeValue::Rfc822Text(v.map(Cow::Borrowed)) + }) + .parse(i) +} + +fn msg_att_uid(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { + map(preceded(tag_no_case("UID "), number), AttributeValue::Uid).parse(i) +} + +// msg-att = "(" (msg-att-dynamic / msg-att-static) +// *(SP (msg-att-dynamic / msg-att-static)) ")" +// +// msg-att-dynamic = "FLAGS" SP "(" [flag-fetch *(SP flag-fetch)] ")" +// ; MAY change for a message +// +// msg-att-static = "ENVELOPE" SP envelope / "INTERNALDATE" SP date-time / +// "RFC822" [".HEADER" / ".TEXT"] SP nstring / +// "RFC822.SIZE" SP number / +// "BODY" ["STRUCTURE"] SP body / +// "BODY" section ["<" number ">"] SP nstring / +// "UID" SP uniqueid +// ; MUST NOT change for a message + +// RFC 8474 §5.1 — EMAILID +// "EMAILID" SP "(" objectid ")" +// objectid = 1*ASTRING-CHAR (RFC 8474 §3). EMAILID is non-optional: a +// compliant server MUST be able to provide one for any stored message, +// so the wire form `EMAILID NIL` is not allowed by the RFC. If a +// non-compliant server emits it anyway, this parser fails and the +// catch-all `msg_att_unknown` below absorbs the attribute. +fn msg_att_emailid(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { + map( + preceded( + tag_no_case("EMAILID "), + paren_delimited(map_res(take_while1(is_astring_char), from_utf8)), + ), + |id| AttributeValue::EmailId(Cow::Borrowed(id)), + ) + .parse(i) +} + +// RFC 8474 §5.2 — THREADID +// "THREADID" SP ( "(" objectid ")" / nil ) +// THREADID may be NIL, which the RFC mandates for messages that do not +// currently have a thread association. We map NIL → `None`. +fn msg_att_threadid(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { + map( + preceded( + tag_no_case("THREADID "), + alt(( + map(nil, |_| None), + map( + paren_delimited(map_res(take_while1(is_astring_char), from_utf8)), + |id| Some(Cow::Borrowed(id)), + ), + )), + ), + AttributeValue::ThreadId, + ) + .parse(i) +} + +// Catch-all for RFC extension attributes not explicitly handled above. +// +// RFC-compliant servers (Apache James, Stalwart, Dovecot) may include +// attributes from extensions that post-date this crate, for example: +// - RFC 8514 §2 SAVEDATE +// - any future extension this crate has not yet typed +// +// When all known parsers fail, this function consumes "name SP value" where +// the value is one of: +// - a single-level parenthesised group `(...)` +// - an nstring (NIL / quoted-string / literal) — covers SAVEDATE and NIL forms +// - a bare atom or number — fallback for any remaining scalar value +// +// The name and value are discarded and `AttributeValue::Unknown` is returned so +// that the rest of the `FETCH` attribute list can be parsed without error. +// Note: deeply nested parenthesised values (beyond one level) are not handled +// by the bare-paren arm; add a dedicated parser if a specific extension needs them. +fn msg_att_unknown(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { + map( + pair( + map_res(take_while1(is_atom_char), from_utf8), + preceded( + tag(" "), + alt(( + value((), paren_delimited(take_while(|c: u8| c != b')'))), + value((), nstring), + value((), take_while1(|c: u8| c != b' ' && c != b')')), + )), + ), + ), + |_| AttributeValue::Unknown, + ) + .parse(i) +} + +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum AttributeValue<'a> { + BodySection { + section: Option, + index: Option, + data: Option>, + }, + BodyStructure(BodyStructure<'a>), + Envelope(Box>), + Flags(Vec>), + InternalDate(Cow<'a, str>), + ModSeq(u64), // RFC 4551, section 3.3.2 + Rfc822(Option>), + Rfc822Header(Option>), + Rfc822Size(u32), + Rfc822Text(Option>), + Uid(u32), + /// + GmailLabels(Vec>), + GmailMsgId(u64), + GmailThrId(u64), + /// RFC 8474 §5.1 — `EMAILID`: a server-assigned unique identifier for + /// a message. RFC 8474 mandates that the server can always provide an + /// EMAILID for any stored message, so this variant is non-optional. + EmailId(Cow<'a, str>), + /// RFC 8474 §5.2 — `THREADID`: a server-assigned identifier for the + /// thread a message belongs to. `None` corresponds to the wire-form + /// `THREADID NIL`, which RFC 8474 §5.2 mandates for messages that do + /// not currently have a thread association. + ThreadId(Option>), + /// An unknown or not-yet-supported FETCH attribute. + /// + /// Returned for any `msg-att` token that the parser does not explicitly + /// recognise (e.g. `SAVEDATE` from RFC 8514, or any future extension). + /// The name and raw value are consumed and discarded so that the rest + /// of the `FETCH` attribute list can be parsed without error. Callers + /// that need the raw value should match on the specific RFC extension + /// and open a tracking issue or PR. + Unknown, +} + +impl<'a> AttributeValue<'a> { + fn parse(i: &'a [u8]) -> IResult<&'a [u8], Self> { + alt(( + msg_att_body_section, + msg_att_body_structure, + msg_att_envelope, + msg_att_internal_date, + msg_att_flags, + rfc4551::msg_att_mod_seq, + msg_att_rfc822, + msg_att_rfc822_header, + msg_att_rfc822_size, + msg_att_rfc822_text, + msg_att_uid, + gmail::msg_att_gmail_labels, + gmail::msg_att_gmail_msgid, + gmail::msg_att_gmail_thrid, + msg_att_emailid, + msg_att_threadid, + msg_att_unknown, + )) + .parse(i) + } + + pub fn into_owned(self) -> AttributeValue<'static> { + match self { + AttributeValue::BodySection { + section, + index, + data, + } => AttributeValue::BodySection { + section, + index, + data: data.map(to_owned_cow), + }, + AttributeValue::BodyStructure(body) => AttributeValue::BodyStructure(body.into_owned()), + AttributeValue::Envelope(e) => AttributeValue::Envelope(Box::new(e.into_owned())), + AttributeValue::Flags(v) => { + AttributeValue::Flags(v.into_iter().map(to_owned_cow).collect()) + } + AttributeValue::InternalDate(v) => AttributeValue::InternalDate(to_owned_cow(v)), + AttributeValue::ModSeq(v) => AttributeValue::ModSeq(v), + AttributeValue::Rfc822(v) => AttributeValue::Rfc822(v.map(to_owned_cow)), + AttributeValue::Rfc822Header(v) => AttributeValue::Rfc822Header(v.map(to_owned_cow)), + AttributeValue::Rfc822Size(v) => AttributeValue::Rfc822Size(v), + AttributeValue::Rfc822Text(v) => AttributeValue::Rfc822Text(v.map(to_owned_cow)), + AttributeValue::Uid(v) => AttributeValue::Uid(v), + AttributeValue::GmailLabels(v) => { + AttributeValue::GmailLabels(v.into_iter().map(to_owned_cow).collect()) + } + AttributeValue::GmailMsgId(v) => AttributeValue::GmailMsgId(v), + AttributeValue::GmailThrId(v) => AttributeValue::GmailThrId(v), + AttributeValue::EmailId(v) => AttributeValue::EmailId(to_owned_cow(v)), + AttributeValue::ThreadId(v) => AttributeValue::ThreadId(v.map(to_owned_cow)), + AttributeValue::Unknown => AttributeValue::Unknown, + } + } +} + +fn msg_att_list(i: &[u8]) -> IResult<&[u8], Vec>> { + parenthesized_nonempty_list(AttributeValue::parse).parse(i) +} + +// message-data = nz-number SP ("EXPUNGE" / ("FETCH" SP msg-att)) +fn message_data_fetch(i: &[u8]) -> IResult<&[u8], Response<'_>> { + map( + (number, tag_no_case(" FETCH "), msg_att_list), + |(num, _, attrs)| Response::Fetch(num, attrs), + ) + .parse(i) +} + +// UIDFETCH (RFC 9586, IMAP UIDONLY extension): identical to FETCH, but the +// leading number is the message UID rather than a sequence number, and the +// UID message-data item may be omitted (it is redundant). Surface it as a +// normal `Response::Fetch` with a synthesized `Uid` attribute so existing +// FETCH consumers work unchanged and `.uid()` resolves. +// [agentmail local patch — see docs/patches/] +fn message_data_uidfetch(i: &[u8]) -> IResult<&[u8], Response<'_>> { + map( + (number, tag_no_case(" UIDFETCH "), msg_att_list), + |(uid, _, mut attrs)| { + if !attrs + .iter() + .any(|attr| matches!(attr, AttributeValue::Uid(_))) + { + attrs.insert(0, AttributeValue::Uid(uid)); + } + Response::Fetch(uid, attrs) + }, + ) + .parse(i) +} + +// message-data = nz-number SP ("EXPUNGE" / ("FETCH" SP msg-att)) +fn message_data_expunge(i: &[u8]) -> IResult<&[u8], u32> { + terminated(number, tag_no_case(" EXPUNGE")).parse(i) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RequestId(pub String); + +impl RequestId { + // tag = 1* + fn parse(i: &[u8]) -> IResult<&[u8], Self> { + map(map_res(take_while1(is_tag_char), from_utf8), |s| { + RequestId(s.to_string()) + }) + .parse(i) + } + + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +#[derive(Debug, Default, Eq, PartialEq)] +pub struct Outcome<'a> { + pub code: Option>, + pub information: Option>, +} + +impl<'a> Outcome<'a> { + // This is not quite according to spec, which mandates the following: + // ["[" resp-text-code "]" SP] text + // However, examples in RFC 4551 (Conditional STORE) counteract this by giving + // examples of `resp-text` that do not include the trailing space and text. + fn parse(i: &'a [u8]) -> IResult<&'a [u8], Self> { + map((opt(ResponseCode::parse), text), |(code, text)| { + let information = if text.is_empty() { + None + } else if code.is_some() { + Some(match text { + Cow::Borrowed(s) => Cow::Borrowed(&s[1..]), + Cow::Owned(s) => Cow::Owned(s[1..].to_string()), + }) + } else { + Some(text) + }; + + Outcome { code, information } + }) + .parse(i) + } + + pub fn into_owned(self) -> Outcome<'static> { + Outcome { + code: self.code.map(ResponseCode::into_owned), + information: self.information.map(to_owned_cow), + } + } +} + +// an response-text if it is at the end of a response. Empty text is then allowed without the normally needed trailing space. +fn trailing_resp_text(i: &[u8]) -> IResult<&[u8], Outcome<'_>> { + map(opt((tag(" "), Outcome::parse)), |resptext| { + resptext.map(|(_, tuple)| tuple).unwrap_or_default() + }) + .parse(i) +} + +// continue-req = "+" SP (resp-text / base64) CRLF +pub(crate) fn continue_req(i: &[u8]) -> IResult<&[u8], Response<'_>> { + // Some servers do not send the space :/ + // TODO: base64 + map( + (tag("+"), opt(tag(" ")), Outcome::parse, tag("\r\n")), + |(_, _, outcome, _)| Response::Continue(outcome), + ) + .parse(i) +} + +// response-tagged = tag SP resp-cond-state CRLF +// +// resp-cond-state = ("OK" / "NO" / "BAD") SP resp-text +// ; Status condition +pub(crate) fn response_tagged(i: &[u8]) -> IResult<&[u8], Response<'_>> { + map( + ( + RequestId::parse, + tag(" "), + Status::parse, + trailing_resp_text, + tag("\r\n"), + ), + |(tag, _, status, outcome, _)| Response::Done { + tag, + status, + outcome, + }, + ) + .parse(i) +} + +// resp-cond-auth = ("OK" / "PREAUTH") SP resp-text +// ; Authentication condition +// +// resp-cond-bye = "BYE" SP resp-text +// +// resp-cond-state = ("OK" / "NO" / "BAD") SP resp-text +// ; Status condition +fn resp_cond(i: &[u8]) -> IResult<&[u8], Response<'_>> { + map((Status::parse, trailing_resp_text), |(status, outcome)| { + Response::Data { status, outcome } + }) + .parse(i) +} + +// response-data = "*" SP (resp-cond-state / resp-cond-bye / +// mailbox-data / message-data / capability-data / quota) CRLF +pub(crate) fn response_data(i: &[u8]) -> IResult<&[u8], Response<'_>> { + delimited( + tag("* "), + alt(( + resp_cond, + map(MailboxDatum::parse, Response::MailboxData), + map(message_data_expunge, Response::Expunge), + message_data_fetch, + message_data_uidfetch, + map(capability_data, Response::Capabilities), + rfc5161::resp_enabled, + rfc5464::metadata_solicited, + rfc5464::metadata_unsolicited, + rfc7162::resp_vanished, + rfc2087::quota, + rfc2087::quota_root, + rfc2971::resp_id, + rfc4314::acl, + rfc4314::list_rights, + rfc4314::my_rights, + )), + preceded( + many0(tag(" ")), // Outlook server sometimes sends whitespace at the end of STATUS response. + tag("\r\n"), + ), + ) + .parse(i) +} + +#[cfg(test)] +mod tests { + use super::{AttributeValue, Capability, NameAttribute}; + use assert_matches::assert_matches; + use std::borrow::Cow; + + #[test] + fn test_list() { + match super::mailbox(b"iNboX ") { + Ok((_, mb)) => { + assert_eq!(mb, "INBOX"); + } + rsp => panic!("unexpected response {rsp:?}"), + } + } + + #[test] + fn test_envelope() { + let env = br#"ENVELOPE ("Wed, 17 Jul 1996 02:23:25 -0700 (PDT)" "IMAP4rev1 WG mtg summary and minutes" (("Terry Gray" NIL "gray" "cac.washington.edu")) (("Terry Gray" NIL "gray" "cac.washington.edu")) (("Terry Gray" NIL "gray" "cac.washington.edu")) ((NIL NIL "imap" "cac.washington.edu")) ((NIL NIL "minutes" "CNRI.Reston.VA.US") ("John Klensin" NIL "KLENSIN" "MIT.EDU")) NIL NIL "") "#; + match super::msg_att_envelope(env) { + Ok((_, AttributeValue::Envelope(_))) => {} + rsp => panic!("unexpected response {rsp:?}"), + } + } + + #[test] + fn test_opt_addresses() { + let addr = b"((NIL NIL \"minutes\" \"CNRI.Reston.VA.US\") (\"John Klensin\" NIL \"KLENSIN\" \"MIT.EDU\")) "; + match super::opt_addresses(addr) { + Ok((_, _addresses)) => {} + rsp => panic!("unexpected response {rsp:?}"), + } + } + + #[test] + fn test_opt_addresses_no_space() { + let addr = + br#"((NIL NIL "test" "example@example.com")(NIL NIL "test" "example@example.com"))"#; + match super::opt_addresses(addr) { + Ok((_, _addresses)) => {} + rsp => panic!("unexpected response {rsp:?}"), + } + } + + #[test] + fn test_addresses() { + match super::Address::parse(b"(\"John Klensin\" NIL \"KLENSIN\" \"MIT.EDU\") ") { + Ok((_, _address)) => {} + rsp => panic!("unexpected response {rsp:?}"), + } + + // Literal non-UTF8 address + match super::Address::parse(b"({12}\r\nJoh\xff Klensin NIL \"KLENSIN\" \"MIT.EDU\") ") { + Ok((_, _address)) => {} + rsp => panic!("unexpected response {rsp:?}"), + } + } + + #[test] + fn test_capability_data() { + // Minimal capabilities + assert_matches!( + super::capability_data(b"CAPABILITY IMAP4rev1\r\n"), + Ok((_, capabilities)) => { + assert_eq!(capabilities, vec![Capability::Imap4rev1]) + } + ); + + assert_matches!( + super::capability_data(b"CAPABILITY XPIG-LATIN IMAP4rev1 STARTTLS AUTH=GSSAPI\r\n"), + Ok((_, capabilities)) => { + assert_eq!(capabilities, vec![ + Capability::Atom(Cow::Borrowed("XPIG-LATIN")), + Capability::Imap4rev1, + Capability::Atom(Cow::Borrowed("STARTTLS")), + Capability::Auth(Cow::Borrowed("GSSAPI")), + ]) + } + ); + + assert_matches!( + super::capability_data(b"CAPABILITY IMAP4rev1 AUTH=GSSAPI AUTH=PLAIN\r\n"), + Ok((_, capabilities)) => { + assert_eq!(capabilities, vec![ + Capability::Imap4rev1, + Capability::Auth(Cow::Borrowed("GSSAPI")), + Capability::Auth(Cow::Borrowed("PLAIN")), + ]) + } + ); + + // Capability command must contain IMAP4rev1 + assert_matches!( + super::capability_data(b"CAPABILITY AUTH=GSSAPI AUTH=PLAIN\r\n"), + Err(_) + ); + } + + #[test] + fn test_surgemail_select_flags() { + // Tests workaround for surgemail with space before closing bracket + assert_matches!( + super::flag_list(b"(\\Answered \\Flagged \\Deleted \\Draft \\Seen $Forwarded )"), + Ok(([], flags)) => { + assert_eq!(flags, vec![ + "\\Answered", + "\\Flagged", + "\\Deleted", + "\\Draft", + "\\Seen", + "$Forwarded" + ]) + } + ); + } + + /// Tests that the [`NameAttribute::into_owned`] method returns the + /// same value (the ownership should only change). + #[test] + fn test_name_attribute_into_owned() { + let name_attributes = [ + // RFC 3501 + NameAttribute::NoInferiors, + NameAttribute::NoSelect, + NameAttribute::Marked, + NameAttribute::Unmarked, + // RFC 6154 + NameAttribute::All, + NameAttribute::Archive, + NameAttribute::Drafts, + NameAttribute::Flagged, + NameAttribute::Junk, + NameAttribute::Sent, + NameAttribute::Trash, + // Extensions not supported by this crate + NameAttribute::Extension(Cow::Borrowed("Foobar")), + ]; + + for name_attribute in name_attributes { + let owned_name_attribute = name_attribute.clone().into_owned(); + assert_eq!(name_attribute, owned_name_attribute); + } + } + + #[test] + fn test_attribute_value_unknown_into_owned() { + assert_eq!( + AttributeValue::Unknown.into_owned(), + AttributeValue::Unknown + ); + } +} diff --git a/vendor/imap-proto/src/types/acls.rs b/vendor/imap-proto/src/rfc4314.rs similarity index 63% rename from vendor/imap-proto/src/types/acls.rs rename to vendor/imap-proto/src/rfc4314.rs index 179825e..dad8d0e 100644 --- a/vendor/imap-proto/src/types/acls.rs +++ b/vendor/imap-proto/src/rfc4314.rs @@ -1,8 +1,25 @@ -use super::to_owned_cow; +//! The IMAP ACL Extension +//! +//! Current +//! +//! +//! Original +//! use std::borrow::Cow; -// IMAP4 ACL Extension 4313/2086 +use nom::{ + bytes::streaming::tag_no_case, + character::complete::{space0, space1}, + combinator::map, + multi::separated_list0, + sequence::{preceded, separated_pair}, + IResult, Parser, +}; + +use crate::core::{astring_utf8, to_owned_cow}; +use crate::rfc3501::mailbox; +use crate::Response; #[derive(Debug, Eq, PartialEq)] pub struct Acl<'a> { @@ -19,6 +36,23 @@ impl<'a> Acl<'a> { } } +/// 3.6. ACL Response +/// ```ignore +/// acl_response ::= "ACL" SP mailbox SP acl_list +/// ``` +pub(crate) fn acl(i: &[u8]) -> IResult<&[u8], Response<'_>> { + let (rest, (_, _, mailbox, acls)) = (tag_no_case("ACL"), space1, mailbox, acl_list).parse(i)?; + + Ok((rest, Response::Acl(Acl { mailbox, acls }))) +} + +/// ```ignore +/// acl_list ::= *(SP acl_entry) +/// ``` +fn acl_list(i: &[u8]) -> IResult<&[u8], Vec>> { + preceded(space0, separated_list0(space1, AclEntry::parse)).parse(i) +} + #[derive(Debug, Eq, PartialEq)] pub struct AclEntry<'a> { pub identifier: Cow<'a, str>, @@ -26,6 +60,20 @@ pub struct AclEntry<'a> { } impl<'a> AclEntry<'a> { + /// ```ignore + /// acl_entry ::= SP identifier SP rights + /// ``` + fn parse(i: &'a [u8]) -> IResult<&'a [u8], Self> { + let (rest, (identifier, rights)) = separated_pair( + astring_utf8, + space1, + map(astring_utf8, |s| map_text_to_rights(&s)), + ) + .parse(i)?; + + Ok((rest, AclEntry { identifier, rights })) + } + pub fn into_owned(self) -> AclEntry<'static> { AclEntry { identifier: to_owned_cow(self.identifier), @@ -53,6 +101,46 @@ impl<'a> ListRights<'a> { } } +/// 3.7. LISTRIGHTS Response +/// ```ignore +/// list_rights_response ::= "LISTRIGHTS" SP mailbox SP identifier SP required_rights *(SP optional_rights) +/// ``` +pub(crate) fn list_rights(i: &[u8]) -> IResult<&[u8], Response<'_>> { + let (rest, (_, _, mailbox, _, identifier, _, required, optional)) = ( + tag_no_case("LISTRIGHTS"), + space1, + mailbox, + space1, + astring_utf8, + space1, + map(astring_utf8, |s| map_text_to_rights(&s)), + list_rights_optional, + ) + .parse(i)?; + + Ok(( + rest, + Response::ListRights(ListRights { + mailbox, + identifier, + required, + optional, + }), + )) +} + +fn list_rights_optional(i: &[u8]) -> IResult<&[u8], Vec> { + let (rest, items) = preceded(space0, separated_list0(space1, astring_utf8)).parse(i)?; + + Ok(( + rest, + items + .into_iter() + .flat_map(|s| s.chars().map(AclRight::from).collect::>()) + .collect(), + )) +} + #[derive(Debug, Eq, PartialEq)] pub struct MyRights<'a> { pub mailbox: Cow<'a, str>, @@ -68,6 +156,28 @@ impl<'a> MyRights<'a> { } } +/// 3.7. MYRIGHTS Response +/// ```ignore +/// my_rights_response ::= "MYRIGHTS" SP mailbox SP rights +/// ``` +pub(crate) fn my_rights(i: &[u8]) -> IResult<&[u8], Response<'_>> { + let (rest, (_, _, mailbox, _, rights)) = ( + tag_no_case("MYRIGHTS"), + space1, + mailbox, + space1, + map(astring_utf8, |s| map_text_to_rights(&s)), + ) + .parse(i)?; + + Ok((rest, Response::MyRights(MyRights { mailbox, rights }))) +} + +/// helper routine to map a string to a vec of AclRights +fn map_text_to_rights(i: &str) -> Vec { + i.chars().map(|c| c.into()).collect() +} + #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] pub enum AclRight { /// l - lookup (mailbox is visible to LIST/LSUB commands, SUBSCRIBE diff --git a/vendor/imap-proto/src/parser/rfc4315.rs b/vendor/imap-proto/src/rfc4315.rs similarity index 68% rename from vendor/imap-proto/src/parser/rfc4315.rs rename to vendor/imap-proto/src/rfc4315.rs index 62a32fb..f5588ab 100644 --- a/vendor/imap-proto/src/parser/rfc4315.rs +++ b/vendor/imap-proto/src/rfc4315.rs @@ -1,20 +1,22 @@ //! -//! https://tools.ietf.org/html/rfc4315 +//! //! //! The IMAP UIDPLUS Extension //! +use std::ops::RangeInclusive; + use nom::{ branch::alt, bytes::streaming::{tag, tag_no_case}, combinator::map, multi::separated_list1, - sequence::{preceded, tuple}, - IResult, + sequence::preceded, + IResult, Parser, }; -use crate::parser::core::number; -use crate::types::*; +use crate::core::number; +use crate::rfc3501::ResponseCode; /// Extends resp-text-code as follows: /// @@ -29,12 +31,10 @@ use crate::types::*; /// [RFC4315 - 3 Additional Response Codes](https://tools.ietf.org/html/rfc4315#section-3) pub(crate) fn resp_text_code_append_uid(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { map( - preceded( - tag_no_case(b"APPENDUID "), - tuple((number, tag(" "), uid_set)), - ), + preceded(tag_no_case("APPENDUID "), (number, tag(" "), uid_set)), |(fst, _, snd)| ResponseCode::AppendUid(fst, snd), - )(i) + ) + .parse(i) } /// Extends resp-text-code as follows: @@ -48,11 +48,12 @@ pub(crate) fn resp_text_code_append_uid(i: &[u8]) -> IResult<&[u8], ResponseCode pub(crate) fn resp_text_code_copy_uid(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { map( preceded( - tag_no_case(b"COPYUID "), - tuple((number, tag(" "), uid_set, tag(" "), uid_set)), + tag_no_case("COPYUID "), + (number, tag(" "), uid_set, tag(" "), uid_set), ), |(fst, _, snd, _, trd)| ResponseCode::CopyUid(fst, snd, trd), - )(i) + ) + .parse(i) } /// Extends resp-text-code as follows: @@ -63,7 +64,7 @@ pub(crate) fn resp_text_code_copy_uid(i: &[u8]) -> IResult<&[u8], ResponseCode<' /// /// [RFC4315 - 3 Additional Response Codes](https://tools.ietf.org/html/rfc4315#section-3) pub(crate) fn resp_text_code_uid_not_sticky(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { - map(tag_no_case(b"UIDNOTSTICKY"), |_| ResponseCode::UidNotSticky)(i) + map(tag_no_case("UIDNOTSTICKY"), |_| ResponseCode::UidNotSticky).parse(i) } /// Parses the uid-set nonterminal: @@ -74,7 +75,32 @@ pub(crate) fn resp_text_code_uid_not_sticky(i: &[u8]) -> IResult<&[u8], Response /// /// [RFC4315 - 4 Formal Syntax](https://tools.ietf.org/html/rfc4315#section-4) fn uid_set(i: &[u8]) -> IResult<&[u8], Vec> { - separated_list1(tag(","), alt((uid_range, map(number, From::from))))(i) + separated_list1(tag(","), UidSetMember::parse).parse(i) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum UidSetMember { + UidRange(RangeInclusive), + Uid(u32), +} + +impl UidSetMember { + // uid-set member: uniqueid / uid-range + fn parse(i: &[u8]) -> IResult<&[u8], Self> { + alt((uid_range, map(number, From::from))).parse(i) + } +} + +impl From> for UidSetMember { + fn from(x: RangeInclusive) -> Self { + UidSetMember::UidRange(x) + } +} + +impl From for UidSetMember { + fn from(x: u32) -> Self { + UidSetMember::Uid(x) + } } /// Parses the uid-set nonterminal: @@ -91,5 +117,6 @@ fn uid_range(i: &[u8]) -> IResult<&[u8], UidSetMember> { map( nom::sequence::separated_pair(number, tag(":"), number), |(fst, snd)| if fst <= snd { fst..=snd } else { snd..=fst }.into(), - )(i) + ) + .parse(i) } diff --git a/vendor/imap-proto/src/parser/rfc4551.rs b/vendor/imap-proto/src/rfc4551.rs similarity index 72% rename from vendor/imap-proto/src/parser/rfc4551.rs rename to vendor/imap-proto/src/rfc4551.rs index f5ca2d7..dd71604 100644 --- a/vendor/imap-proto/src/parser/rfc4551.rs +++ b/vendor/imap-proto/src/rfc4551.rs @@ -1,15 +1,15 @@ //! -//! https://tools.ietf.org/html/rfc4551 +//! //! //! IMAP Extension for Conditional STORE Operation //! or Quick Flag Changes Resynchronization //! -use nom::{bytes::streaming::tag_no_case, sequence::tuple, IResult}; +use nom::{bytes::streaming::tag_no_case, IResult, Parser}; use crate::{ - parser::core::{number_64, paren_delimited}, - types::*, + core::{number_64, paren_delimited}, + rfc3501::{AttributeValue, ResponseCode, StatusAttribute}, }; // The highest mod-sequence value of all messages in the mailbox. @@ -17,7 +17,7 @@ use crate::{ // [RFC4551 - 3.6 HIGHESTMODSEQ Status Data Items](https://tools.ietf.org/html/rfc4551#section-3.6) // [RFC4551 - 4. Formal Syntax - resp-text-code](https://tools.ietf.org/html/rfc4551#section-4) pub(crate) fn resp_text_code_highest_mod_seq(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { - let (i, (_, num)) = tuple((tag_no_case("HIGHESTMODSEQ "), number_64))(i)?; + let (i, (_, num)) = (tag_no_case("HIGHESTMODSEQ "), number_64).parse(i)?; Ok((i, ResponseCode::HighestModSeq(num))) } @@ -25,12 +25,12 @@ pub(crate) fn resp_text_code_highest_mod_seq(i: &[u8]) -> IResult<&[u8], Respons // [RFC4551 - 3.6 - HIGHESTMODSEQ Status Data Items](https://tools.ietf.org/html/rfc4551#section-3.6) // [RFC4551 - 4. Formal Syntax - status-att-val](https://tools.ietf.org/html/rfc4551#section-4) pub(crate) fn status_att_val_highest_mod_seq(i: &[u8]) -> IResult<&[u8], StatusAttribute> { - let (i, (_, num)) = tuple((tag_no_case("HIGHESTMODSEQ "), number_64))(i)?; + let (i, (_, num)) = (tag_no_case("HIGHESTMODSEQ "), number_64).parse(i)?; Ok((i, StatusAttribute::HighestModSeq(num))) } // [RFC4551 - 4. Formal Syntax - fetch-mod-resp](https://tools.ietf.org/html/rfc4551#section-4) pub(crate) fn msg_att_mod_seq(i: &[u8]) -> IResult<&[u8], AttributeValue<'_>> { - let (i, (_, num)) = tuple((tag_no_case("MODSEQ "), paren_delimited(number_64)))(i)?; + let (i, (_, num)) = (tag_no_case("MODSEQ "), paren_delimited(number_64)).parse(i)?; Ok((i, AttributeValue::ModSeq(num))) } diff --git a/vendor/imap-proto/src/parser/rfc5161.rs b/vendor/imap-proto/src/rfc5161.rs similarity index 57% rename from vendor/imap-proto/src/parser/rfc5161.rs rename to vendor/imap-proto/src/rfc5161.rs index ce7fe66..eea1f4e 100644 --- a/vendor/imap-proto/src/parser/rfc5161.rs +++ b/vendor/imap-proto/src/rfc5161.rs @@ -1,37 +1,35 @@ //! -//! https://tools.ietf.org/html/rfc5161 +//! //! //! The IMAP ENABLE Extension //! use nom::{ - bytes::streaming::tag_no_case, - character::streaming::char, - combinator::map, - multi::many0, - sequence::{preceded, tuple}, - IResult, + bytes::streaming::tag_no_case, character::streaming::char, combinator::map, multi::many0, + sequence::preceded, IResult, Parser, }; use std::borrow::Cow; -use crate::parser::core::atom; -use crate::types::*; +use crate::core::atom; +use crate::rfc3501::Capability; +use crate::Response; // The ENABLED response lists capabilities that were enabled in response // to a ENABLE command. // [RFC5161 - 3.2 The ENABLED Response](https://tools.ietf.org/html/rfc5161#section-3.2) pub(crate) fn resp_enabled(i: &[u8]) -> IResult<&[u8], Response<'_>> { - map(enabled_data, Response::Capabilities)(i) + map(enabled_data, Response::Capabilities).parse(i) } fn enabled_data(i: &[u8]) -> IResult<&[u8], Vec>> { - let (i, (_, capabilities)) = tuple(( + let (i, (_, capabilities)) = ( tag_no_case("ENABLED"), many0(preceded(char(' '), capability)), - ))(i)?; + ) + .parse(i)?; Ok((i, capabilities)) } fn capability(i: &[u8]) -> IResult<&[u8], Capability<'_>> { - map(map(atom, Cow::Borrowed), Capability::Atom)(i) + map(map(atom, Cow::Borrowed), Capability::Atom).parse(i) } diff --git a/vendor/imap-proto/src/parser/rfc5256.rs b/vendor/imap-proto/src/rfc5256.rs similarity index 85% rename from vendor/imap-proto/src/parser/rfc5256.rs rename to vendor/imap-proto/src/rfc5256.rs index a705d39..a3b31d0 100644 --- a/vendor/imap-proto/src/parser/rfc5256.rs +++ b/vendor/imap-proto/src/rfc5256.rs @@ -1,5 +1,5 @@ //! -//! https://tools.ietf.org/html/rfc5256 +//! //! //! SORT extension //! @@ -9,10 +9,10 @@ use nom::{ combinator::{map, opt}, multi::many0, sequence::{preceded, terminated}, - IResult, + IResult, Parser, }; -use crate::{parser::core::number, types::MailboxDatum}; +use crate::{core::number, rfc3501::MailboxDatum}; /// BASE.7.2.SORT. SORT Response /// @@ -38,9 +38,10 @@ pub(crate) fn mailbox_data_sort(i: &[u8]) -> IResult<&[u8], MailboxDatum<'_>> { // Since the SORT command extends the SEARCH command, the trailing whitespace // is exceptionnaly allowed here (as for the SEARCH command). terminated( - preceded(tag_no_case(b"SORT"), many0(preceded(tag(" "), number))), + preceded(tag_no_case("SORT"), many0(preceded(tag(" "), number))), opt(tag(" ")), ), MailboxDatum::Sort, - )(i) + ) + .parse(i) } diff --git a/vendor/imap-proto/src/parser/rfc5464.rs b/vendor/imap-proto/src/rfc5464.rs similarity index 84% rename from vendor/imap-proto/src/parser/rfc5464.rs rename to vendor/imap-proto/src/rfc5464.rs index b8b98d2..f17adbe 100644 --- a/vendor/imap-proto/src/parser/rfc5464.rs +++ b/vendor/imap-proto/src/rfc5464.rs @@ -1,5 +1,5 @@ //! -//! https://tools.ietf.org/html/rfc5464 +//! //! //! IMAP METADATA extension //! @@ -9,12 +9,15 @@ use nom::{ bytes::streaming::{tag, tag_no_case}, combinator::map, multi::separated_list0, - sequence::tuple, - IResult, + IResult, Parser, }; use std::borrow::Cow; -use crate::{parser::core::*, types::*}; +use crate::{ + core::*, + rfc3501::{MailboxDatum, ResponseCode}, + Response, +}; fn is_entry_component_char(c: u8) -> bool { c < 0x80 && c > 0x19 && c != b'*' && c != b'%' && c != b'/' @@ -124,41 +127,49 @@ fn slice_to_str(i: &[u8]) -> &str { } fn nil_value(i: &[u8]) -> IResult<&[u8], Option> { - map(tag_no_case("NIL"), |_| None)(i) + map(tag_no_case("NIL"), |_| None).parse(i) } fn string_value(i: &[u8]) -> IResult<&[u8], Option> { map(alt((quoted, literal)), |s| { Some(slice_to_str(s).to_string()) - })(i) + }) + .parse(i) +} + +#[derive(Debug, Eq, PartialEq, Clone)] +pub struct Metadata { + pub entry: String, + pub value: Option, } fn keyval_list(i: &[u8]) -> IResult<&[u8], Vec> { parenthesized_nonempty_list(map( - tuple(( + ( map(entry_name, slice_to_str), tag(" "), alt((nil_value, string_value)), - )), + ), |(key, _, value)| Metadata { entry: key.to_string(), value, }, - ))(i) + )) + .parse(i) } fn entry_list(i: &[u8]) -> IResult<&[u8], Vec>> { - separated_list0(tag(" "), map(map(entry_name, slice_to_str), Cow::Borrowed))(i) + separated_list0(tag(" "), map(map(entry_name, slice_to_str), Cow::Borrowed)).parse(i) } fn metadata_common(i: &[u8]) -> IResult<&[u8], &[u8]> { - let (i, (_, mbox, _)) = tuple((tag_no_case("METADATA "), quoted, tag(" ")))(i)?; + let (i, (_, mbox, _)) = (tag_no_case("METADATA "), quoted, tag(" ")).parse(i)?; Ok((i, mbox)) } // [RFC5464 - 4.4.1 METADATA Response with values] pub(crate) fn metadata_solicited(i: &[u8]) -> IResult<&[u8], Response<'_>> { - let (i, (mailbox, values)) = tuple((metadata_common, keyval_list))(i)?; + let (i, (mailbox, values)) = (metadata_common, keyval_list).parse(i)?; Ok(( i, Response::MailboxData(MailboxDatum::MetadataSolicited { @@ -170,7 +181,7 @@ pub(crate) fn metadata_solicited(i: &[u8]) -> IResult<&[u8], Response<'_>> { // [RFC5464 - 4.4.2 Unsolicited METADATA Response without values] pub(crate) fn metadata_unsolicited(i: &[u8]) -> IResult<&[u8], Response<'_>> { - let (i, (mailbox, values)) = tuple((metadata_common, entry_list))(i)?; + let (i, (mailbox, values)) = (metadata_common, entry_list).parse(i)?; Ok(( i, Response::MailboxData(MailboxDatum::MetadataUnsolicited { @@ -185,7 +196,7 @@ pub(crate) fn metadata_unsolicited(i: &[u8]) -> IResult<&[u8], Response<'_>> { // [RFC5464 - 4.2.1 MAXSIZE GETMETADATA Command Option](https://tools.ietf.org/html/rfc5464#section-4.2.1) // [RFC5464 - 5. Formal Syntax - resp-text-code](https://tools.ietf.org/html/rfc5464#section-5) pub(crate) fn resp_text_code_metadata_long_entries(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { - let (i, (_, num)) = tuple((tag_no_case("METADATA LONGENTRIES "), number_64))(i)?; + let (i, (_, num)) = (tag_no_case("METADATA LONGENTRIES "), number_64).parse(i)?; Ok((i, ResponseCode::MetadataLongEntries(num))) } @@ -194,7 +205,7 @@ pub(crate) fn resp_text_code_metadata_long_entries(i: &[u8]) -> IResult<&[u8], R // [RFC5464 - 4.3 SETMETADATA Command](https://tools.ietf.org/html/rfc5464#section-4.3) // [RFC5464 - 5. Formal Syntax - resp-text-code](https://tools.ietf.org/html/rfc5464#section-5) pub(crate) fn resp_text_code_metadata_max_size(i: &[u8]) -> IResult<&[u8], ResponseCode<'_>> { - let (i, (_, num)) = tuple((tag_no_case("METADATA MAXSIZE "), number_64))(i)?; + let (i, (_, num)) = (tag_no_case("METADATA MAXSIZE "), number_64).parse(i)?; Ok((i, ResponseCode::MetadataMaxSize(num))) } @@ -218,8 +229,8 @@ pub(crate) fn resp_text_code_metadata_no_private(i: &[u8]) -> IResult<&[u8], Res #[cfg(test)] mod tests { - use super::{metadata_solicited, metadata_unsolicited}; - use crate::types::*; + use super::{metadata_solicited, metadata_unsolicited, MailboxDatum, Response, ResponseCode}; + use crate::rfc3501::{Outcome, Status}; use std::borrow::Cow; #[test] @@ -350,51 +361,61 @@ mod tests { #[test] fn test_response_codes() { - use crate::parser::parse_response; - - match parse_response(b"* OK [METADATA LONGENTRIES 123] Some entries omitted.\r\n") { + match Response::parse(b"* OK [METADATA LONGENTRIES 123] Some entries omitted.\r\n") { Ok(( _, Response::Data { status: Status::Ok, - code: Some(ResponseCode::MetadataLongEntries(123)), - information: Some(Cow::Borrowed("Some entries omitted.")), + outcome: + Outcome { + code: Some(ResponseCode::MetadataLongEntries(123)), + information: Some(Cow::Borrowed("Some entries omitted.")), + }, }, )) => {} rsp => panic!("unexpected response {rsp:?}"), } - match parse_response(b"* NO [METADATA MAXSIZE 123] Annotation too large.\r\n") { + match Response::parse(b"* NO [METADATA MAXSIZE 123] Annotation too large.\r\n") { Ok(( _, Response::Data { status: Status::No, - code: Some(ResponseCode::MetadataMaxSize(123)), - information: Some(Cow::Borrowed("Annotation too large.")), + outcome: + Outcome { + code: Some(ResponseCode::MetadataMaxSize(123)), + information: Some(Cow::Borrowed("Annotation too large.")), + }, }, )) => {} rsp => panic!("unexpected response {rsp:?}"), } - match parse_response(b"* NO [METADATA TOOMANY] Too many annotations.\r\n") { + match Response::parse(b"* NO [METADATA TOOMANY] Too many annotations.\r\n") { Ok(( _, Response::Data { status: Status::No, - code: Some(ResponseCode::MetadataTooMany), - information: Some(Cow::Borrowed("Too many annotations.")), + outcome: + Outcome { + code: Some(ResponseCode::MetadataTooMany), + information: Some(Cow::Borrowed("Too many annotations.")), + }, }, )) => {} rsp => panic!("unexpected response {rsp:?}"), } - match parse_response(b"* NO [METADATA NOPRIVATE] Private annotations not supported.\r\n") { + match Response::parse(b"* NO [METADATA NOPRIVATE] Private annotations not supported.\r\n") { Ok(( _, Response::Data { status: Status::No, - code: Some(ResponseCode::MetadataNoPrivate), - information: Some(Cow::Borrowed("Private annotations not supported.")), + outcome: + Outcome { + code: Some(ResponseCode::MetadataNoPrivate), + information: Some(Cow::Borrowed("Private annotations not supported.")), + }, }, )) => {} rsp => panic!("unexpected response {rsp:?}"), diff --git a/vendor/imap-proto/src/parser/rfc7162.rs b/vendor/imap-proto/src/rfc7162.rs similarity index 74% rename from vendor/imap-proto/src/parser/rfc7162.rs rename to vendor/imap-proto/src/rfc7162.rs index 4643698..99cf737 100644 --- a/vendor/imap-proto/src/parser/rfc7162.rs +++ b/vendor/imap-proto/src/rfc7162.rs @@ -1,17 +1,16 @@ //! //! -//! https://tools.ietf.org/html/rfc7162 +//! //! //! The IMAP QRESYNC Extensions //! use nom::{ - bytes::streaming::tag_no_case, character::streaming::space1, combinator::opt, sequence::tuple, - IResult, + bytes::streaming::tag_no_case, character::streaming::space1, combinator::opt, IResult, Parser, }; -use crate::parser::core::sequence_set; -use crate::types::*; +use crate::core::sequence_set; +use crate::Response; // The VANISHED response reports that the specified UIDs have been // permanently removed from the mailbox. This response is similar to @@ -20,12 +19,13 @@ use crate::types::*; // numbers. // [RFC7162 - VANISHED RESPONSE](https://tools.ietf.org/html/rfc7162#section-3.2.10) pub(crate) fn resp_vanished(i: &[u8]) -> IResult<&[u8], Response<'_>> { - let (rest, (_, earlier, _, uids)) = tuple(( + let (rest, (_, earlier, _, uids)) = ( tag_no_case("VANISHED"), - opt(tuple((space1, tag_no_case("(EARLIER)")))), + opt((space1, tag_no_case("(EARLIER)"))), space1, sequence_set, - ))(i)?; + ) + .parse(i)?; Ok(( rest, Response::Vanished { diff --git a/vendor/imap-proto/src/parser/tests.rs b/vendor/imap-proto/src/tests.rs similarity index 64% rename from vendor/imap-proto/src/parser/tests.rs rename to vendor/imap-proto/src/tests.rs index f8704ed..08123c6 100644 --- a/vendor/imap-proto/src/parser/tests.rs +++ b/vendor/imap-proto/src/tests.rs @@ -1,11 +1,18 @@ -use super::{bodystructure::BodyStructParser, parse_response}; -use crate::types::*; +use super::bodystructure::BodyStructParser; +use crate::rfc3501::body::{MessageSection, SectionPath}; +use crate::rfc3501::body_structure::BodyStructure; +use crate::rfc3501::{ + AttributeValue, Capability, MailboxDatum, MailboxListData, NameAttribute, Outcome, + ResponseCode, Status, StatusAttribute, +}; +use crate::rfc4314::{Acl, AclEntry, AclRight, ListRights, MyRights}; +use crate::Response; use std::borrow::Cow; use std::num::NonZeroUsize; #[test] fn test_mailbox_data_response() { - match parse_response(b"* LIST (\\HasNoChildren) \".\" INBOX.Tests\r\n") { + match Response::parse(b"* LIST (\\HasNoChildren) \".\" INBOX.Tests\r\n") { Ok((_, Response::MailboxData(_))) => {} rsp => panic!("unexpected response {rsp:?}"), } @@ -15,14 +22,14 @@ fn test_mailbox_data_response() { /// and extensions can be parsed. #[test] fn test_name_attributes() { - match parse_response( + match Response::parse( b"* LIST (\\Noinferiors \\Noselect \\Marked \\Unmarked \\All \\Archive \\Drafts \\Flagged \\Junk \\Sent \\Trash \\Foobar) \".\" INBOX.Tests\r\n", ) { Ok(( _, - Response::MailboxData(MailboxDatum::List { + Response::MailboxData(MailboxDatum::List(MailboxListData { name_attributes, .. - }), + })), )) => { assert_eq!( name_attributes, @@ -52,7 +59,7 @@ fn test_name_attributes() { /// Test the ACL response from RFC 4314/2086 #[test] fn test_acl_response() { - match parse_response(b"* ACL INBOX user lrswipkxtecdan\r\n") { + match Response::parse(b"* ACL INBOX user lrswipkxtecdan\r\n") { Ok((_, Response::Acl(_))) => {} rsp => panic!("unexpected response {rsp:?}"), } @@ -61,7 +68,7 @@ fn test_acl_response() { #[test] fn test_acl_attributes() { // no rights - match parse_response(b"* ACL INBOX\r\n") { + match Response::parse(b"* ACL INBOX\r\n") { Ok((_, Response::Acl(acl))) => { assert_eq!( acl, @@ -75,7 +82,7 @@ fn test_acl_attributes() { } // one right pair - match parse_response(b"* ACL INBOX user lrswipkxtecdan\r\n") { + match Response::parse(b"* ACL INBOX user lrswipkxtecdan\r\n") { Ok((_, Response::Acl(acl))) => { assert_eq!( acl, @@ -107,7 +114,7 @@ fn test_acl_attributes() { } // with custom rights - match parse_response(b"* ACL INBOX user lr0123\r\n") { + match Response::parse(b"* ACL INBOX user lr0123\r\n") { Ok((_, Response::Acl(acl))) => { assert_eq!( acl, @@ -131,7 +138,7 @@ fn test_acl_attributes() { } // multiple right pairs - match parse_response(b"* ACL INBOX user lrswipkxtecdan user2 lr\r\n") { + match Response::parse(b"* ACL INBOX user lrswipkxtecdan user2 lr\r\n") { Ok((_, Response::Acl(acl))) => { assert_eq!( acl, @@ -169,7 +176,7 @@ fn test_acl_attributes() { } // quoted mailbox - match parse_response(b"* ACL \"My folder\" user lrswipkxtecdan\r\n") { + match Response::parse(b"* ACL \"My folder\" user lrswipkxtecdan\r\n") { Ok((_, Response::Acl(acl))) => { assert_eq!( acl, @@ -201,7 +208,7 @@ fn test_acl_attributes() { } // quoted identifier - match parse_response(b"* ACL Trash \"user name\" lrswipkxtecdan\r\n") { + match Response::parse(b"* ACL Trash \"user name\" lrswipkxtecdan\r\n") { Ok((_, Response::Acl(acl))) => { assert_eq!( acl, @@ -236,7 +243,7 @@ fn test_acl_attributes() { /// Test the LISTRIGHTS response from RFC 4314/2086 #[test] fn test_list_rights_response() { - match parse_response(b"* LISTRIGHTS INBOX user lkxca r s w i p t e d n\r\n") { + match Response::parse(b"* LISTRIGHTS INBOX user lkxca r s w i p t e d n\r\n") { Ok((_, Response::ListRights(_))) => {} rsp => panic!("unexpected response {rsp:?}"), } @@ -245,7 +252,7 @@ fn test_list_rights_response() { #[test] fn test_list_rights_attributes() { // no required/always rights, and no optional rights - match parse_response(b"* LISTRIGHTS INBOX user \"\"\r\n") { + match Response::parse(b"* LISTRIGHTS INBOX user \"\"\r\n") { Ok((_, Response::ListRights(rights))) => { assert_eq!( rights, @@ -261,7 +268,7 @@ fn test_list_rights_attributes() { } // no required/always rights, and with optional rights - match parse_response(b"* LISTRIGHTS INBOX user \"\" l k x c\r\n") { + match Response::parse(b"* LISTRIGHTS INBOX user \"\" l k x c\r\n") { Ok((_, Response::ListRights(rights))) => { assert_eq!( rights, @@ -282,7 +289,7 @@ fn test_list_rights_attributes() { } // with required/always rights, and with optional rights - match parse_response(b"* LISTRIGHTS INBOX user lkr x c\r\n") { + match Response::parse(b"* LISTRIGHTS INBOX user lkr x c\r\n") { Ok((_, Response::ListRights(rights))) => { assert_eq!( rights, @@ -298,7 +305,7 @@ fn test_list_rights_attributes() { } // with required/always rights, and no optional rights - match parse_response(b"* LISTRIGHTS INBOX user lkr\r\n") { + match Response::parse(b"* LISTRIGHTS INBOX user lkr\r\n") { Ok((_, Response::ListRights(rights))) => { assert_eq!( rights, @@ -314,7 +321,7 @@ fn test_list_rights_attributes() { } // with mailbox with spaces - match parse_response(b"* LISTRIGHTS \"My Folder\" user lkr x c\r\n") { + match Response::parse(b"* LISTRIGHTS \"My Folder\" user lkr x c\r\n") { Ok((_, Response::ListRights(rights))) => { assert_eq!( rights, @@ -333,7 +340,7 @@ fn test_list_rights_attributes() { /// Test the MYRIGHTS response from RFC 4314/2086 #[test] fn test_my_rights_response() { - match parse_response(b"* MYRIGHTS INBOX lkxca\r\n") { + match Response::parse(b"* MYRIGHTS INBOX lkxca\r\n") { Ok((_, Response::MyRights(_))) => {} rsp => panic!("unexpected response {rsp:?}"), } @@ -342,7 +349,7 @@ fn test_my_rights_response() { #[test] fn test_my_rights_attributes() { // with rights - match parse_response(b"* MYRIGHTS INBOX lkr\r\n") { + match Response::parse(b"* MYRIGHTS INBOX lkr\r\n") { Ok((_, Response::MyRights(rights))) => { assert_eq!( rights, @@ -356,7 +363,7 @@ fn test_my_rights_attributes() { } // with space in mailbox - match parse_response(b"* MYRIGHTS \"My Folder\" lkr\r\n") { + match Response::parse(b"* MYRIGHTS \"My Folder\" lkr\r\n") { Ok((_, Response::MyRights(rights))) => { assert_eq!( rights, @@ -372,7 +379,7 @@ fn test_my_rights_attributes() { #[test] fn test_number_overflow() { - match parse_response(b"* 2222222222222222222222222222222222222222222C\r\n") { + match Response::parse(b"* 2222222222222222222222222222222222222222222C\r\n") { Err(_) => {} _ => panic!("error required for integer overflow"), } @@ -380,13 +387,16 @@ fn test_number_overflow() { #[test] fn test_unseen() { - match parse_response(b"* OK [UNSEEN 3] Message 3 is first unseen\r\n").unwrap() { + match Response::parse(b"* OK [UNSEEN 3] Message 3 is first unseen\r\n").unwrap() { ( _, Response::Data { status: Status::Ok, - code: Some(ResponseCode::Unseen(3)), - information: Some(Cow::Borrowed("Message 3 is first unseen")), + outcome: + Outcome { + code: Some(ResponseCode::Unseen(3)), + information: Some(Cow::Borrowed("Message 3 is first unseen")), + }, }, ) => {} rsp => panic!("unexpected response {rsp:?}"), @@ -395,7 +405,7 @@ fn test_unseen() { #[test] fn test_body_text() { - match parse_response(b"* 2 FETCH (BODY[TEXT] {3}\r\nfoo)\r\n") { + match Response::parse(b"* 2 FETCH (BODY[TEXT] {3}\r\nfoo)\r\n") { Ok((_, Response::Fetch(_, attrs))) => { let body = &attrs[0]; assert_eq!( @@ -415,7 +425,7 @@ fn test_body_text() { #[test] fn test_body_structure() { const RESPONSE: &[u8] = b"* 15 FETCH (BODYSTRUCTURE (\"TEXT\" \"PLAIN\" (\"CHARSET\" \"iso-8859-1\") NIL NIL \"QUOTED-PRINTABLE\" 1315 42 NIL NIL NIL NIL))\r\n"; - match parse_response(RESPONSE) { + match Response::parse(RESPONSE) { Ok((_, Response::Fetch(_, attrs))) => { let body = &attrs[0]; assert!( @@ -427,9 +437,69 @@ fn test_body_structure() { } } +// Coverage for the lossy-decode (Cow::Owned) branch of resp_text: +// a server greeting with non-UTF-8 bytes in the message text (here +// Latin-1 "Willkommen" with ü = 0xFC) must parse without failing, +// and the information field must come back as an owned Cow with +// the U+FFFD replacement character. +#[test] +fn test_resp_text_lossy_decode_8bit() { + let mut response: Vec = Vec::new(); + response.extend_from_slice(b"* OK Willk"); + response.push(0xFC); // Latin-1 'ü', not valid UTF-8 + response.extend_from_slice(b"ommen\r\n"); + + match Response::parse(&response) { + Ok(( + _, + Response::Data { + status: Status::Ok, + outcome: + Outcome { + code: None, + information: Some(info), + }, + }, + )) => { + assert!( + matches!(info, Cow::Owned(_)), + "expected lossy Owned, got {info:?}" + ); + assert_eq!(info, "Willk\u{FFFD}ommen"); + } + rsp => panic!("unexpected response {rsp:?}"), + } +} + +// Regression: BODYSTRUCTURE with an 8-bit literal in a body parameter +// (e.g. a MIME filename in Latin-1 / ISO-8859-9). Real-world IMAP +// servers (Dovecot, Cyrus) emit these for mails from clients with +// non-UTF-8 locales (Turkish "Görüntü1" is a common case from Outlook +// for Turkish). The parser must accept the bytes via lossy UTF-8 +// decoding instead of failing the entire response. +#[test] +fn test_body_structure_with_8bit_literal_filename() { + // ("name" {8}\r\n\xf6\xfc\xfc<1>) — "Görüntü1" in Latin-1 + let mut response: Vec = Vec::new(); + response.extend_from_slice(b"* 15 FETCH (BODYSTRUCTURE (\"image\" \"png\" (\"name\" {8}\r\n"); + response.extend_from_slice(&[0x47, 0xF6, 0x72, 0xFC, 0x6E, 0x74, 0xFC, 0x31]); + response + .extend_from_slice(b") \"\" NIL \"base64\" 15440 NIL NIL NIL NIL))\r\n"); + match Response::parse(&response) { + Ok((_, Response::Fetch(_, attrs))) => { + assert!( + matches!(attrs[0], AttributeValue::BodyStructure(_)), + "body = {:?}", + attrs[0] + ); + } + rsp => panic!("unexpected response {rsp:?}"), + } +} + #[test] fn test_status() { - match parse_response(b"* STATUS blurdybloop (MESSAGES 231 UIDNEXT 44292)\r\n") { + match Response::parse(b"* STATUS blurdybloop (MESSAGES 231 UIDNEXT 44292)\r\n") { Ok((_, Response::MailboxData(MailboxDatum::Status { mailbox, status }))) => { assert_eq!(mailbox, "blurdybloop"); assert_eq!( @@ -444,7 +514,7 @@ fn test_status() { } // Outlook server sends a STATUS response with a space in the end. - match parse_response(b"* STATUS Sent (UIDNEXT 107) \r\n") { + match Response::parse(b"* STATUS Sent (UIDNEXT 107) \r\n") { Ok((_, Response::MailboxData(MailboxDatum::Status { mailbox, status }))) => { assert_eq!(mailbox, "Sent"); assert_eq!(status, [StatusAttribute::UidNext(107),]); @@ -453,7 +523,7 @@ fn test_status() { } // mail.163.com sends a STATUS response with an empty list when asked for (UIDNEXT) - match parse_response(b"* STATUS \"INBOX\" ()\r\n") { + match Response::parse(b"* STATUS \"INBOX\" ()\r\n") { Ok((_, Response::MailboxData(MailboxDatum::Status { mailbox, status }))) => { assert_eq!(mailbox, "INBOX"); assert_eq!(status, []); @@ -464,21 +534,21 @@ fn test_status() { #[test] fn test_notify() { - match parse_response(b"* 3501 EXPUNGE\r\n") { + match Response::parse(b"* 3501 EXPUNGE\r\n") { Ok((_, Response::Expunge(3501))) => {} rsp => panic!("unexpected response {rsp:?}"), } - match parse_response(b"* 3501 EXISTS\r\n") { + match Response::parse(b"* 3501 EXISTS\r\n") { Ok((_, Response::MailboxData(MailboxDatum::Exists(3501)))) => {} rsp => panic!("unexpected response {rsp:?}"), } - match parse_response(b"+ idling\r\n") { + match Response::parse(b"+ idling\r\n") { Ok(( _, - Response::Continue { + Response::Continue(Outcome { code: None, information: Some(Cow::Borrowed("idling")), - }, + }), )) => {} rsp => panic!("unexpected response {rsp:?}"), } @@ -488,7 +558,7 @@ fn test_notify() { fn test_search() { // also allow trailing whitespace in SEARCH responses for empty_response in &["* SEARCH\r\n", "* SEARCH \r\n"] { - match parse_response(empty_response.as_bytes()) { + match Response::parse(empty_response.as_bytes()) { Ok((_, Response::MailboxData(MailboxDatum::Search(ids)))) => { assert!(ids.is_empty()); } @@ -496,7 +566,7 @@ fn test_search() { } } for response in &["* SEARCH 12345 67890\r\n", "* SEARCH 12345 67890 \r\n"] { - match parse_response(response.as_bytes()) { + match Response::parse(response.as_bytes()) { Ok((_, Response::MailboxData(MailboxDatum::Search(ids)))) => { assert_eq!(ids[0], 12345); assert_eq!(ids[1], 67890); @@ -510,7 +580,7 @@ fn test_search() { fn test_sort() { // also allow trailing whitespace in SEARCH responses for empty_response in &["* SORT\r\n", "* SORT \r\n"] { - match parse_response(empty_response.as_bytes()) { + match Response::parse(empty_response.as_bytes()) { Ok((_, Response::MailboxData(MailboxDatum::Sort(ids)))) => { assert!(ids.is_empty()); } @@ -518,7 +588,7 @@ fn test_sort() { } } for response in &["* SORT 12345 67890\r\n", "* SORT 12345 67890 \r\n"] { - match parse_response(response.as_bytes()) { + match Response::parse(response.as_bytes()) { Ok((_, Response::MailboxData(MailboxDatum::Sort(ids)))) => { assert_eq!(ids[0], 12345); assert_eq!(ids[1], 67890); @@ -530,7 +600,7 @@ fn test_sort() { #[test] fn test_uid_fetch() { - match parse_response(b"* 4 FETCH (UID 71372 RFC822.HEADER {10275}\r\n") { + match Response::parse(b"* 4 FETCH (UID 71372 RFC822.HEADER {10275}\r\n") { Err(nom::Err::Incomplete(nom::Needed::Size(size))) => { assert_eq!(size, NonZeroUsize::new(10275).unwrap()); } @@ -541,7 +611,7 @@ fn test_uid_fetch() { #[test] fn test_uid_fetch_extra_space() { // DavMail inserts an extra space after RFC822.HEADER - match parse_response(b"* 4 FETCH (UID 71372 RFC822.HEADER {10275}\r\n") { + match Response::parse(b"* 4 FETCH (UID 71372 RFC822.HEADER {10275}\r\n") { Err(nom::Err::Incomplete(nom::Needed::Size(size))) => { assert_eq!(size, NonZeroUsize::new(10275).unwrap()); } @@ -553,7 +623,7 @@ fn test_uid_fetch_extra_space() { fn test_header_fields() { const RESPONSE: &[u8] = b"* 1 FETCH (UID 1 BODY[HEADER.FIELDS (CHAT-VERSION)] {21}\r\nChat-Version: 1.0\r\n\r\n)\r\n"; - match parse_response(RESPONSE) { + match Response::parse(RESPONSE) { Ok((_, Response::Fetch(_, _))) => {} rsp => panic!("unexpected response {rsp:?}"), } @@ -561,37 +631,46 @@ fn test_header_fields() { #[test] fn test_response_codes() { - match parse_response(b"* OK [ALERT] Alert!\r\n") { + match Response::parse(b"* OK [ALERT] Alert!\r\n") { Ok(( _, Response::Data { status: Status::Ok, - code: Some(ResponseCode::Alert), - information: Some(Cow::Borrowed("Alert!")), + outcome: + Outcome { + code: Some(ResponseCode::Alert), + information: Some(Cow::Borrowed("Alert!")), + }, }, )) => {} rsp => panic!("unexpected response {rsp:?}"), } - match parse_response(b"* NO [PARSE] Something\r\n") { + match Response::parse(b"* NO [PARSE] Something\r\n") { Ok(( _, Response::Data { status: Status::No, - code: Some(ResponseCode::Parse), - information: Some(Cow::Borrowed("Something")), + outcome: + Outcome { + code: Some(ResponseCode::Parse), + information: Some(Cow::Borrowed("Something")), + }, }, )) => {} rsp => panic!("unexpected response {rsp:?}"), } - match parse_response(b"* OK [CAPABILITY IMAP4rev1 IDLE] Logged in\r\n") { + match Response::parse(b"* OK [CAPABILITY IMAP4rev1 IDLE] Logged in\r\n") { Ok(( _, Response::Data { status: Status::Ok, - code: Some(ResponseCode::Capabilities(c)), - information: Some(Cow::Borrowed("Logged in")), + outcome: + Outcome { + code: Some(ResponseCode::Capabilities(c)), + information: Some(Cow::Borrowed("Logged in")), + }, }, )) => { assert_eq!(c.len(), 2); @@ -601,13 +680,16 @@ fn test_response_codes() { rsp => panic!("unexpected response {rsp:?}"), } - match parse_response(b"* OK [CAPABILITY UIDPLUS IMAP4rev1 IDLE] Logged in\r\n") { + match Response::parse(b"* OK [CAPABILITY UIDPLUS IMAP4rev1 IDLE] Logged in\r\n") { Ok(( _, Response::Data { status: Status::Ok, - code: Some(ResponseCode::Capabilities(c)), - information: Some(Cow::Borrowed("Logged in")), + outcome: + Outcome { + code: Some(ResponseCode::Capabilities(c)), + information: Some(Cow::Borrowed("Logged in")), + }, }, )) => { assert_eq!(c.len(), 3); @@ -619,37 +701,66 @@ fn test_response_codes() { } // Missing IMAP4rev1 - match parse_response(b"* OK [CAPABILITY UIDPLUS IDLE] Logged in\r\n") { + match Response::parse(b"* OK [CAPABILITY UIDPLUS IDLE] Logged in\r\n") { Ok(( _, Response::Data { status: Status::Ok, - code: None, - information: Some(Cow::Borrowed("[CAPABILITY UIDPLUS IDLE] Logged in")), + outcome: + Outcome { + code: None, + information: Some(Cow::Borrowed("[CAPABILITY UIDPLUS IDLE] Logged in")), + }, }, )) => {} rsp => panic!("unexpected response {rsp:?}"), } - match parse_response(b"* NO [BADCHARSET] error\r\n") { + // "Logged in" response with UTF-8-encoded en dash (U+2013 codepoint) in it. + // This was returned by mail.systemausfall.org on 2026-09-14. + match Response::parse(b"* OK [CAPABILITY IMAP4rev1 LOGIN-REFERRALS ID ENABLE IDLE SASL-IR LITERAL+ AUTH=PLAIN AUTH=LOGIN AUTH=XOAUTH2] Logged in \xe2\x80\x93 go ahead!\r\n") { + Ok(( + _, + Response::Data { + status: Status::Ok, + outcome: + Outcome { + code: Some(ResponseCode::Capabilities(_)), + information: + Some(Cow::Borrowed( + "Logged in \u{2013} go ahead!", + )), + }, + }, + )) => {} + rsp => panic!("unexpected response {rsp:?}"), + } + + match Response::parse(b"* NO [BADCHARSET] error\r\n") { Ok(( _, Response::Data { status: Status::No, - code: Some(ResponseCode::BadCharset(None)), - information: Some(Cow::Borrowed("error")), + outcome: + Outcome { + code: Some(ResponseCode::BadCharset(None)), + information: Some(Cow::Borrowed("error")), + }, }, )) => {} rsp => panic!("unexpected response {rsp:?}"), } - match parse_response(b"* NO [BADCHARSET (utf-8 latin1)] error\r\n") { + match Response::parse(b"* NO [BADCHARSET (utf-8 latin1)] error\r\n") { Ok(( _, Response::Data { status: Status::No, - code: Some(ResponseCode::BadCharset(Some(v))), - information: Some(Cow::Borrowed("error")), + outcome: + Outcome { + code: Some(ResponseCode::BadCharset(Some(v))), + information: Some(Cow::Borrowed("error")), + }, }, )) => { assert_eq!(v.len(), 2); @@ -659,13 +770,16 @@ fn test_response_codes() { rsp => panic!("unexpected response {rsp:?}"), } - match parse_response(b"* NO [BADCHARSET ()] error\r\n") { + match Response::parse(b"* NO [BADCHARSET ()] error\r\n") { Ok(( _, Response::Data { status: Status::No, - code: None, - information: Some(Cow::Borrowed("[BADCHARSET ()] error")), + outcome: + Outcome { + code: None, + information: Some(Cow::Borrowed("[BADCHARSET ()] error")), + }, }, )) => {} rsp => panic!("unexpected response {rsp:?}"), @@ -674,7 +788,7 @@ fn test_response_codes() { #[test] fn test_incomplete_fetch() { - match parse_response(b"* 4644 FETCH (UID ") { + match Response::parse(b"* 4644 FETCH (UID ") { Err(nom::Err::Incomplete(_)) => {} rsp => panic!("should be incomplete: {rsp:?}"), } @@ -684,31 +798,31 @@ fn test_incomplete_fetch() { fn test_fetch_response_whitespace_tolerance() { // Allow extra whitespace before the closing parenthesis. let input = b"* 11784 FETCH (UID 87567 INTERNALDATE \"06-May-2023 18:48:30 +0200\" RFC822.SIZE 5799845 )\r\n"; - assert!(parse_response(input).is_ok()); + assert!(Response::parse(input).is_ok()); } #[test] fn test_continuation() { // regular RFC compliant - match parse_response(b"+ \r\n") { + match Response::parse(b"+ \r\n") { Ok(( _, - Response::Continue { + Response::Continue(Outcome { code: None, information: None, - }, + }), )) => {} rsp => panic!("unexpected response {rsp:?}"), } // short version, sent by yandex - match parse_response(b"+\r\n") { + match Response::parse(b"+\r\n") { Ok(( _, - Response::Continue { + Response::Continue(Outcome { code: None, information: None, - }, + }), )) => {} rsp => panic!("unexpected response {rsp:?}"), } @@ -716,7 +830,7 @@ fn test_continuation() { #[test] fn test_enabled() { - match parse_response(b"* ENABLED QRESYNC X-GOOD-IDEA\r\n") { + match Response::parse(b"* ENABLED QRESYNC X-GOOD-IDEA\r\n") { Ok((_, capabilities)) => assert_eq!( capabilities, Response::Capabilities(vec![ @@ -733,7 +847,7 @@ fn test_flags() { // Invalid response (FLAGS can't include \*) from Zoho Mail server. // // As a workaround, such response is parsed without error. - match parse_response(b"* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \\*)\r\n") { + match Response::parse(b"* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \\*)\r\n") { Ok((_, capabilities)) => assert_eq!( capabilities, Response::MailboxData(MailboxDatum::Flags(vec![ @@ -751,7 +865,7 @@ fn test_flags() { // Invalid response (FLAGS can't include ']') from some unknown providers. // // As a workaround, such response is parsed without error. - match parse_response(b"* FLAGS (OIB-Seen-[Gmail]/All)\r\n") { + match Response::parse(b"* FLAGS (OIB-Seen-[Gmail]/All)\r\n") { Ok((_, capabilities)) => assert_eq!( capabilities, Response::MailboxData(MailboxDatum::Flags(vec![Cow::Borrowed( @@ -764,7 +878,7 @@ fn test_flags() { #[test] fn test_vanished() { - match parse_response(b"* VANISHED (EARLIER) 1,2,3:8\r\n") { + match Response::parse(b"* VANISHED (EARLIER) 1,2,3:8\r\n") { Ok((_, Response::Vanished { earlier, uids })) => { assert!(earlier); assert_eq!(uids.len(), 3); @@ -781,7 +895,7 @@ fn test_vanished() { rsp => panic!("Unexpected response: {rsp:?}"), } - match parse_response(b"* VANISHED 1,2,3:8,10\r\n") { + match Response::parse(b"* VANISHED 1,2,3:8,10\r\n") { Ok((_, Response::Vanished { earlier, uids })) => { assert!(!earlier); assert_eq!(uids.len(), 4); @@ -789,7 +903,7 @@ fn test_vanished() { rsp => panic!("Unexpected response: {rsp:?}"), } - match parse_response(b"* VANISHED (EARLIER) 1\r\n") { + match Response::parse(b"* VANISHED (EARLIER) 1\r\n") { Ok((_, Response::Vanished { earlier, uids })) => { assert!(earlier); assert_eq!(uids.len(), 1); @@ -798,7 +912,7 @@ fn test_vanished() { rsp => panic!("Unexpected response: {rsp:?}"), } - match parse_response(b"* VANISHED 1\r\n") { + match Response::parse(b"* VANISHED 1\r\n") { Ok((_, Response::Vanished { earlier, uids })) => { assert!(!earlier); assert_eq!(uids.len(), 1); @@ -806,48 +920,57 @@ fn test_vanished() { rsp => panic!("Unexpected response: {rsp:?}"), } - assert!(parse_response(b"* VANISHED \r\n").is_err()); - assert!(parse_response(b"* VANISHED (EARLIER) \r\n").is_err()); + assert!(Response::parse(b"* VANISHED \r\n").is_err()); + assert!(Response::parse(b"* VANISHED (EARLIER) \r\n").is_err()); } #[test] fn test_uidplus() { - match dbg!(parse_response( + match dbg!(Response::parse( b"* OK [APPENDUID 38505 3955] APPEND completed\r\n" )) { Ok(( _, Response::Data { status: Status::Ok, - code: Some(ResponseCode::AppendUid(38505, uid_set)), - information: Some(Cow::Borrowed("APPEND completed")), + outcome: + Outcome { + code: Some(ResponseCode::AppendUid(38505, uid_set)), + information: Some(Cow::Borrowed("APPEND completed")), + }, }, )) if uid_set == [3955.into()] => {} rsp => panic!("Unexpected response: {rsp:?}"), } - match dbg!(parse_response( + match dbg!(Response::parse( b"* OK [COPYUID 38505 304,319:320 3956:3958] Done\r\n" )) { Ok(( _, Response::Data { status: Status::Ok, - code: Some(ResponseCode::CopyUid(38505, uid_set_src, uid_set_dst)), - information: Some(Cow::Borrowed("Done")), + outcome: + Outcome { + code: Some(ResponseCode::CopyUid(38505, uid_set_src, uid_set_dst)), + information: Some(Cow::Borrowed("Done")), + }, }, )) if uid_set_src == [304.into(), (319..=320).into()] && uid_set_dst == [(3956..=3958).into()] => {} rsp => panic!("Unexpected response: {rsp:?}"), } - match dbg!(parse_response( + match dbg!(Response::parse( b"* NO [UIDNOTSTICKY] Non-persistent UIDs\r\n" )) { Ok(( _, Response::Data { status: Status::No, - code: Some(ResponseCode::UidNotSticky), - information: Some(Cow::Borrowed("Non-persistent UIDs")), + outcome: + Outcome { + code: Some(ResponseCode::UidNotSticky), + information: Some(Cow::Borrowed("Non-persistent UIDs")), + }, }, )) => {} rsp => panic!("Unexpected response: {rsp:?}"), @@ -892,7 +1015,7 @@ fn test_imap_body_structure() { )\ )\r\n"; - let (_, resp) = parse_response(test).unwrap(); + let (_, resp) = Response::parse(test).unwrap(); match resp { Response::Fetch(_, f) => { let bodystructure = f @@ -918,13 +1041,16 @@ fn test_imap_body_structure() { #[test] fn test_parsing_of_quota_capability_in_login_response() { - match parse_response(b"* OK [CAPABILITY IMAP4rev1 IDLE QUOTA] Logged in\r\n") { + match Response::parse(b"* OK [CAPABILITY IMAP4rev1 IDLE QUOTA] Logged in\r\n") { Ok(( _, Response::Data { status: Status::Ok, - code: Some(ResponseCode::Capabilities(c)), - information: Some(Cow::Borrowed("Logged in")), + outcome: + Outcome { + code: Some(ResponseCode::Capabilities(c)), + information: Some(Cow::Borrowed("Logged in")), + }, }, )) => { assert_eq!(c.len(), 3); @@ -938,26 +1064,129 @@ fn test_parsing_of_quota_capability_in_login_response() { #[test] fn test_parsing_of_bye_response() { - match parse_response(b"* BYE\r\n") { + match Response::parse(b"* BYE\r\n") { Ok(( _, Response::Data { status: Status::Bye, - code: None, - information: None, + outcome: + Outcome { + code: None, + information: None, + }, }, )) => {} rsp => panic!("unexpected response {rsp:?}"), }; - match parse_response(b"* BYE Autologout; idle for too long\r\n") { + match Response::parse(b"* BYE Autologout; idle for too long\r\n") { Ok(( _, Response::Data { status: Status::Bye, - code: None, - information: Some(Cow::Borrowed("Autologout; idle for too long")), + outcome: + Outcome { + code: None, + information: Some(Cow::Borrowed("Autologout; idle for too long")), + }, }, )) => {} rsp => panic!("unexpected response {rsp:?}"), }; } + +// ── RFC 8474 OBJECTID (EMAILID / THREADID) — Apache James 3.9 ─────────────── + +#[test] +fn test_fetch_rfc8474_emailid() { + // James sends EMAILID in FETCH responses when the client requests it. + match Response::parse( + b"* 1 FETCH (UID 123 EMAILID (M6d952b5c6f82bfd8) BODY[]<0> {5}\r\nhello)\r\n", + ) { + Ok((_, Response::Fetch(1, attrs))) => { + assert_eq!(attrs.len(), 3); + assert!(matches!(attrs[0], AttributeValue::Uid(123))); + match &attrs[1] { + AttributeValue::EmailId(id) => assert_eq!(id.as_ref(), "M6d952b5c6f82bfd8"), + other => panic!("expected EmailId, got {other:?}"), + } + assert!(matches!( + attrs[2], + AttributeValue::BodySection { index: Some(0), .. } + )); + } + rsp => panic!("unexpected response {rsp:?}"), + } +} + +#[test] +fn test_fetch_rfc8474_threadid() { + match Response::parse( + b"* 1 FETCH (UID 123 THREADID (T64b4c7e452f961e2) BODY[]<0> {5}\r\nhello)\r\n", + ) { + Ok((_, Response::Fetch(1, attrs))) => { + assert_eq!(attrs.len(), 3); + match &attrs[1] { + AttributeValue::ThreadId(Some(id)) => { + assert_eq!(id.as_ref(), "T64b4c7e452f961e2"); + } + other => panic!("expected ThreadId(Some), got {other:?}"), + } + } + rsp => panic!("unexpected response {rsp:?}"), + } +} + +#[test] +fn test_fetch_rfc8474_threadid_nil() { + // RFC 8474 §5.2: THREADID can be NIL for messages with no thread association. + match Response::parse(b"* 1 FETCH (UID 7 THREADID NIL BODY[]<0> {2}\r\nhi)\r\n") { + Ok((_, Response::Fetch(1, attrs))) => { + assert_eq!(attrs.len(), 3); + assert!(matches!(attrs[1], AttributeValue::ThreadId(None))); + } + rsp => panic!("unexpected response {rsp:?}"), + } +} + +#[test] +fn test_fetch_rfc8474_emailid_and_threadid() { + // Both attributes in the same FETCH response. + match Response::parse( + b"* 1 FETCH (UID 42 EMAILID (Mdeadbeef) THREADID (Tcafebabe) BODY[]<0> {3}\r\nhey)\r\n", + ) { + Ok((_, Response::Fetch(1, attrs))) => { + assert_eq!(attrs.len(), 4); + assert!(matches!(attrs[0], AttributeValue::Uid(42))); + match &attrs[1] { + AttributeValue::EmailId(id) => assert_eq!(id.as_ref(), "Mdeadbeef"), + other => panic!("expected EmailId, got {other:?}"), + } + match &attrs[2] { + AttributeValue::ThreadId(Some(id)) => assert_eq!(id.as_ref(), "Tcafebabe"), + other => panic!("expected ThreadId(Some), got {other:?}"), + } + } + rsp => panic!("unexpected response {rsp:?}"), + } +} + +#[test] +fn test_fetch_unknown_savedate_fallback() { + // RFC 8514 §2: SAVEDATE is not yet typed first-class. Confirm the + // catch-all `msg_att_unknown` still absorbs unknown extension + // attributes so that the rest of the FETCH list keeps parsing. + match Response::parse( + b"* 1 FETCH (UID 9 SAVEDATE \"01-Jan-2025 12:00:00 +0000\" BODY[]<0> {2}\r\nok)\r\n", + ) { + Ok((_, Response::Fetch(1, attrs))) => { + assert_eq!(attrs.len(), 3); + assert!(matches!(attrs[0], AttributeValue::Uid(9))); + assert!(matches!(attrs[1], AttributeValue::Unknown)); + assert!(matches!( + attrs[2], + AttributeValue::BodySection { index: Some(0), .. } + )); + } + rsp => panic!("unexpected response {rsp:?}"), + } +} diff --git a/vendor/imap-proto/src/types.rs b/vendor/imap-proto/src/types.rs deleted file mode 100644 index 5f42175..0000000 --- a/vendor/imap-proto/src/types.rs +++ /dev/null @@ -1,978 +0,0 @@ -use std::borrow::Cow; -use std::collections::HashMap; -use std::ops::RangeInclusive; - -pub mod acls; -pub use acls::*; - -fn to_owned_cow(c: Cow<'_, T>) -> Cow<'static, T> { - Cow::Owned(c.into_owned()) -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Request<'a>(pub Cow<'a, [u8]>, pub Cow<'a, [u8]>); - -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub enum AttrMacro { - All, - Fast, - Full, -} - -#[derive(Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum Response<'a> { - Capabilities(Vec>), - Continue { - code: Option>, - information: Option>, - }, - Done { - tag: RequestId, - status: Status, - code: Option>, - information: Option>, - }, - Data { - status: Status, - code: Option>, - information: Option>, - }, - Expunge(u32), - Vanished { - earlier: bool, - uids: Vec>, - }, - Fetch(u32, Vec>), - MailboxData(MailboxDatum<'a>), - Quota(Quota<'a>), - QuotaRoot(QuotaRoot<'a>), - Id(Option, Cow<'a, str>>>), - Acl(Acl<'a>), - ListRights(ListRights<'a>), - MyRights(MyRights<'a>), -} - -impl<'a> Response<'a> { - pub fn from_bytes(buf: &'a [u8]) -> crate::ParseResult<'a> { - crate::parser::parse_response(buf) - } - - pub fn into_owned(self) -> Response<'static> { - match self { - Response::Capabilities(capabilities) => Response::Capabilities( - capabilities - .into_iter() - .map(Capability::into_owned) - .collect(), - ), - Response::Continue { code, information } => Response::Continue { - code: code.map(ResponseCode::into_owned), - information: information.map(to_owned_cow), - }, - Response::Done { - tag, - status, - code, - information, - } => Response::Done { - tag, - status, - code: code.map(ResponseCode::into_owned), - information: information.map(to_owned_cow), - }, - Response::Data { - status, - code, - information, - } => Response::Data { - status, - code: code.map(ResponseCode::into_owned), - information: information.map(to_owned_cow), - }, - Response::Expunge(seq) => Response::Expunge(seq), - Response::Vanished { earlier, uids } => Response::Vanished { earlier, uids }, - Response::Fetch(seq, attrs) => Response::Fetch( - seq, - attrs.into_iter().map(AttributeValue::into_owned).collect(), - ), - Response::MailboxData(datum) => Response::MailboxData(datum.into_owned()), - Response::Quota(quota) => Response::Quota(quota.into_owned()), - Response::QuotaRoot(quota_root) => Response::QuotaRoot(quota_root.into_owned()), - Response::Id(map) => Response::Id(map.map(|m| { - m.into_iter() - .map(|(k, v)| (to_owned_cow(k), to_owned_cow(v))) - .collect() - })), - Response::Acl(acl_list) => Response::Acl(acl_list.into_owned()), - Response::ListRights(rights) => Response::ListRights(rights.into_owned()), - Response::MyRights(rights) => Response::MyRights(rights.into_owned()), - } - } -} - -#[derive(Debug, Eq, PartialEq)] -pub enum Status { - Ok, - No, - Bad, - PreAuth, - Bye, -} - -#[derive(Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum ResponseCode<'a> { - Alert, - BadCharset(Option>>), - Capabilities(Vec>), - HighestModSeq(u64), // RFC 4551, section 3.1.1 - Parse, - PermanentFlags(Vec>), - ReadOnly, - ReadWrite, - TryCreate, - UidNext(u32), - UidValidity(u32), - Unseen(u32), - AppendUid(u32, Vec), - CopyUid(u32, Vec, Vec), - UidNotSticky, - MetadataLongEntries(u64), // RFC 5464, section 4.2.1 - MetadataMaxSize(u64), // RFC 5464, section 4.3 - MetadataTooMany, // RFC 5464, section 4.3 - MetadataNoPrivate, // RFC 5464, section 4.3 -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum UidSetMember { - UidRange(RangeInclusive), - Uid(u32), -} - -impl From> for UidSetMember { - fn from(x: RangeInclusive) -> Self { - UidSetMember::UidRange(x) - } -} - -impl From for UidSetMember { - fn from(x: u32) -> Self { - UidSetMember::Uid(x) - } -} - -impl<'a> ResponseCode<'a> { - pub fn into_owned(self) -> ResponseCode<'static> { - match self { - ResponseCode::Alert => ResponseCode::Alert, - ResponseCode::BadCharset(v) => { - ResponseCode::BadCharset(v.map(|vs| vs.into_iter().map(to_owned_cow).collect())) - } - ResponseCode::Capabilities(v) => { - ResponseCode::Capabilities(v.into_iter().map(Capability::into_owned).collect()) - } - ResponseCode::HighestModSeq(v) => ResponseCode::HighestModSeq(v), - ResponseCode::Parse => ResponseCode::Parse, - ResponseCode::PermanentFlags(v) => { - ResponseCode::PermanentFlags(v.into_iter().map(to_owned_cow).collect()) - } - ResponseCode::ReadOnly => ResponseCode::ReadOnly, - ResponseCode::ReadWrite => ResponseCode::ReadWrite, - ResponseCode::TryCreate => ResponseCode::TryCreate, - ResponseCode::UidNext(v) => ResponseCode::UidNext(v), - ResponseCode::UidValidity(v) => ResponseCode::UidValidity(v), - ResponseCode::Unseen(v) => ResponseCode::Unseen(v), - ResponseCode::AppendUid(a, b) => ResponseCode::AppendUid(a, b), - ResponseCode::CopyUid(a, b, c) => ResponseCode::CopyUid(a, b, c), - ResponseCode::UidNotSticky => ResponseCode::UidNotSticky, - ResponseCode::MetadataLongEntries(v) => ResponseCode::MetadataLongEntries(v), - ResponseCode::MetadataMaxSize(v) => ResponseCode::MetadataMaxSize(v), - ResponseCode::MetadataTooMany => ResponseCode::MetadataTooMany, - ResponseCode::MetadataNoPrivate => ResponseCode::MetadataNoPrivate, - } - } -} - -#[derive(Debug, Eq, PartialEq, Clone)] -#[non_exhaustive] -pub enum StatusAttribute { - HighestModSeq(u64), // RFC 4551 - Messages(u32), - Recent(u32), - UidNext(u32), - UidValidity(u32), - Unseen(u32), -} - -#[derive(Debug, Eq, PartialEq, Clone)] -pub struct Metadata { - pub entry: String, - pub value: Option, -} - -#[derive(Debug, Eq, PartialEq, Clone)] -#[non_exhaustive] -pub enum MailboxDatum<'a> { - Exists(u32), - Flags(Vec>), - List { - name_attributes: Vec>, - delimiter: Option>, - name: Cow<'a, str>, - }, - Search(Vec), - Sort(Vec), - Status { - mailbox: Cow<'a, str>, - status: Vec, - }, - Recent(u32), - MetadataSolicited { - mailbox: Cow<'a, str>, - values: Vec, - }, - MetadataUnsolicited { - mailbox: Cow<'a, str>, - values: Vec>, - }, - GmailLabels(Vec>), - GmailMsgId(u64), - GmailThrId(u64), -} - -impl<'a> MailboxDatum<'a> { - pub fn into_owned(self) -> MailboxDatum<'static> { - match self { - MailboxDatum::Exists(seq) => MailboxDatum::Exists(seq), - MailboxDatum::Flags(flags) => { - MailboxDatum::Flags(flags.into_iter().map(to_owned_cow).collect()) - } - MailboxDatum::List { - name_attributes, - delimiter, - name, - } => MailboxDatum::List { - name_attributes: name_attributes - .into_iter() - .map(|named_attribute| named_attribute.into_owned()) - .collect(), - delimiter: delimiter.map(to_owned_cow), - name: to_owned_cow(name), - }, - MailboxDatum::Search(seqs) => MailboxDatum::Search(seqs), - MailboxDatum::Sort(seqs) => MailboxDatum::Sort(seqs), - MailboxDatum::Status { mailbox, status } => MailboxDatum::Status { - mailbox: to_owned_cow(mailbox), - status, - }, - MailboxDatum::Recent(seq) => MailboxDatum::Recent(seq), - MailboxDatum::MetadataSolicited { mailbox, values } => { - MailboxDatum::MetadataSolicited { - mailbox: to_owned_cow(mailbox), - values, - } - } - MailboxDatum::MetadataUnsolicited { mailbox, values } => { - MailboxDatum::MetadataUnsolicited { - mailbox: to_owned_cow(mailbox), - values: values.into_iter().map(to_owned_cow).collect(), - } - } - MailboxDatum::GmailLabels(labels) => { - MailboxDatum::GmailLabels(labels.into_iter().map(to_owned_cow).collect()) - } - MailboxDatum::GmailMsgId(msgid) => MailboxDatum::GmailMsgId(msgid), - MailboxDatum::GmailThrId(thrid) => MailboxDatum::GmailThrId(thrid), - } - } -} - -#[derive(Debug, Eq, PartialEq, Hash)] -pub enum Capability<'a> { - Imap4rev1, - Auth(Cow<'a, str>), - Atom(Cow<'a, str>), -} - -impl<'a> Capability<'a> { - pub fn into_owned(self) -> Capability<'static> { - match self { - Capability::Imap4rev1 => Capability::Imap4rev1, - Capability::Auth(v) => Capability::Auth(to_owned_cow(v)), - Capability::Atom(v) => Capability::Atom(to_owned_cow(v)), - } - } -} - -#[derive(Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum Attribute { - Body, - Envelope, - Flags, - InternalDate, - ModSeq, // RFC 4551, section 3.3.2 - Rfc822, - Rfc822Size, - Rfc822Text, - Uid, - /// https://developers.google.com/gmail/imap/imap-extensions#access_to_gmail_labels_x-gm-labels - GmailLabels, - GmailMsgId, - GmailThrId, -} - -#[derive(Debug, Eq, PartialEq)] -pub enum MessageSection { - Header, - Mime, - Text, -} - -#[derive(Debug, Eq, PartialEq)] -pub enum SectionPath { - Full(MessageSection), - Part(Vec, Option), -} - -#[allow(clippy::large_enum_variant)] -#[derive(Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum AttributeValue<'a> { - BodySection { - section: Option, - index: Option, - data: Option>, - }, - BodyStructure(BodyStructure<'a>), - Envelope(Box>), - Flags(Vec>), - InternalDate(Cow<'a, str>), - ModSeq(u64), // RFC 4551, section 3.3.2 - Rfc822(Option>), - Rfc822Header(Option>), - Rfc822Size(u32), - Rfc822Text(Option>), - Uid(u32), - /// https://developers.google.com/gmail/imap/imap-extensions#access_to_gmail_labels_x-gm-labels - GmailLabels(Vec>), - GmailMsgId(u64), - GmailThrId(u64), -} - -impl<'a> AttributeValue<'a> { - pub fn into_owned(self) -> AttributeValue<'static> { - match self { - AttributeValue::BodySection { - section, - index, - data, - } => AttributeValue::BodySection { - section, - index, - data: data.map(to_owned_cow), - }, - AttributeValue::BodyStructure(body) => AttributeValue::BodyStructure(body.into_owned()), - AttributeValue::Envelope(e) => AttributeValue::Envelope(Box::new(e.into_owned())), - AttributeValue::Flags(v) => { - AttributeValue::Flags(v.into_iter().map(to_owned_cow).collect()) - } - AttributeValue::InternalDate(v) => AttributeValue::InternalDate(to_owned_cow(v)), - AttributeValue::ModSeq(v) => AttributeValue::ModSeq(v), - AttributeValue::Rfc822(v) => AttributeValue::Rfc822(v.map(to_owned_cow)), - AttributeValue::Rfc822Header(v) => AttributeValue::Rfc822Header(v.map(to_owned_cow)), - AttributeValue::Rfc822Size(v) => AttributeValue::Rfc822Size(v), - AttributeValue::Rfc822Text(v) => AttributeValue::Rfc822Text(v.map(to_owned_cow)), - AttributeValue::Uid(v) => AttributeValue::Uid(v), - AttributeValue::GmailLabels(v) => { - AttributeValue::GmailLabels(v.into_iter().map(to_owned_cow).collect()) - } - AttributeValue::GmailMsgId(v) => AttributeValue::GmailMsgId(v), - AttributeValue::GmailThrId(v) => AttributeValue::GmailThrId(v), - } - } -} - -#[allow(clippy::large_enum_variant)] -#[derive(Debug, Eq, PartialEq)] -pub enum BodyStructure<'a> { - Basic { - common: BodyContentCommon<'a>, - other: BodyContentSinglePart<'a>, - extension: Option>, - }, - Text { - common: BodyContentCommon<'a>, - other: BodyContentSinglePart<'a>, - lines: u32, - extension: Option>, - }, - Message { - common: BodyContentCommon<'a>, - other: BodyContentSinglePart<'a>, - envelope: Envelope<'a>, - body: Box>, - lines: u32, - extension: Option>, - }, - Multipart { - common: BodyContentCommon<'a>, - bodies: Vec>, - extension: Option>, - }, -} - -impl<'a> BodyStructure<'a> { - pub fn into_owned(self) -> BodyStructure<'static> { - match self { - BodyStructure::Basic { - common, - other, - extension, - } => BodyStructure::Basic { - common: common.into_owned(), - other: other.into_owned(), - extension: extension.map(|v| v.into_owned()), - }, - BodyStructure::Text { - common, - other, - lines, - extension, - } => BodyStructure::Text { - common: common.into_owned(), - other: other.into_owned(), - lines, - extension: extension.map(|v| v.into_owned()), - }, - BodyStructure::Message { - common, - other, - envelope, - body, - lines, - extension, - } => BodyStructure::Message { - common: common.into_owned(), - other: other.into_owned(), - envelope: envelope.into_owned(), - body: Box::new(body.into_owned()), - lines, - extension: extension.map(|v| v.into_owned()), - }, - BodyStructure::Multipart { - common, - bodies, - extension, - } => BodyStructure::Multipart { - common: common.into_owned(), - bodies: bodies.into_iter().map(|v| v.into_owned()).collect(), - extension: extension.map(|v| v.into_owned()), - }, - } - } -} - -#[derive(Debug, Eq, PartialEq)] -pub struct BodyContentCommon<'a> { - pub ty: ContentType<'a>, - pub disposition: Option>, - pub language: Option>>, - pub location: Option>, -} - -impl<'a> BodyContentCommon<'a> { - pub fn into_owned(self) -> BodyContentCommon<'static> { - BodyContentCommon { - ty: self.ty.into_owned(), - disposition: self.disposition.map(|v| v.into_owned()), - language: self - .language - .map(|v| v.into_iter().map(to_owned_cow).collect()), - location: self.location.map(to_owned_cow), - } - } -} - -#[derive(Debug, Eq, PartialEq)] -pub struct BodyContentSinglePart<'a> { - pub id: Option>, - pub md5: Option>, - pub description: Option>, - pub transfer_encoding: ContentEncoding<'a>, - pub octets: u32, -} - -impl<'a> BodyContentSinglePart<'a> { - pub fn into_owned(self) -> BodyContentSinglePart<'static> { - BodyContentSinglePart { - id: self.id.map(to_owned_cow), - md5: self.md5.map(to_owned_cow), - description: self.description.map(to_owned_cow), - transfer_encoding: self.transfer_encoding.into_owned(), - octets: self.octets, - } - } -} - -#[derive(Debug, Eq, PartialEq)] -pub struct ContentType<'a> { - pub ty: Cow<'a, str>, - pub subtype: Cow<'a, str>, - pub params: BodyParams<'a>, -} - -impl<'a> ContentType<'a> { - pub fn into_owned(self) -> ContentType<'static> { - ContentType { - ty: to_owned_cow(self.ty), - subtype: to_owned_cow(self.subtype), - params: body_param_owned(self.params), - } - } -} - -#[derive(Debug, Eq, PartialEq)] -pub struct ContentDisposition<'a> { - pub ty: Cow<'a, str>, - pub params: BodyParams<'a>, -} - -impl<'a> ContentDisposition<'a> { - pub fn into_owned(self) -> ContentDisposition<'static> { - ContentDisposition { - ty: to_owned_cow(self.ty), - params: body_param_owned(self.params), - } - } -} - -#[derive(Debug, Eq, PartialEq)] -pub enum ContentEncoding<'a> { - SevenBit, - EightBit, - Binary, - Base64, - QuotedPrintable, - Other(Cow<'a, str>), -} - -impl<'a> ContentEncoding<'a> { - pub fn into_owned(self) -> ContentEncoding<'static> { - match self { - ContentEncoding::SevenBit => ContentEncoding::SevenBit, - ContentEncoding::EightBit => ContentEncoding::EightBit, - ContentEncoding::Binary => ContentEncoding::Binary, - ContentEncoding::Base64 => ContentEncoding::Base64, - ContentEncoding::QuotedPrintable => ContentEncoding::QuotedPrintable, - ContentEncoding::Other(v) => ContentEncoding::Other(to_owned_cow(v)), - } - } -} - -#[derive(Debug, Eq, PartialEq)] -pub enum BodyExtension<'a> { - Num(u32), - Str(Option>), - List(Vec>), -} - -impl<'a> BodyExtension<'a> { - pub fn into_owned(self) -> BodyExtension<'static> { - match self { - BodyExtension::Num(v) => BodyExtension::Num(v), - BodyExtension::Str(v) => BodyExtension::Str(v.map(to_owned_cow)), - BodyExtension::List(v) => { - BodyExtension::List(v.into_iter().map(|v| v.into_owned()).collect()) - } - } - } -} - -pub type BodyParams<'a> = Option, Cow<'a, str>)>>; - -fn body_param_owned(v: BodyParams<'_>) -> BodyParams<'static> { - v.map(|v| { - v.into_iter() - .map(|(k, v)| (to_owned_cow(k), to_owned_cow(v))) - .collect() - }) -} - -/// An RFC 2822 envelope -/// -/// See https://datatracker.ietf.org/doc/html/rfc2822#section-3.6 for more details. -#[derive(Debug, Eq, PartialEq)] -pub struct Envelope<'a> { - pub date: Option>, - pub subject: Option>, - /// Author of the message; mailbox responsible for writing the message - pub from: Option>>, - /// Mailbox of the agent responsible for the message's transmission - pub sender: Option>>, - /// Mailbox that the author of the message suggests replies be sent to - pub reply_to: Option>>, - pub to: Option>>, - pub cc: Option>>, - pub bcc: Option>>, - pub in_reply_to: Option>, - pub message_id: Option>, -} - -impl<'a> Envelope<'a> { - pub fn into_owned(self) -> Envelope<'static> { - Envelope { - date: self.date.map(to_owned_cow), - subject: self.subject.map(to_owned_cow), - from: self - .from - .map(|v| v.into_iter().map(|v| v.into_owned()).collect()), - sender: self - .sender - .map(|v| v.into_iter().map(|v| v.into_owned()).collect()), - reply_to: self - .reply_to - .map(|v| v.into_iter().map(|v| v.into_owned()).collect()), - to: self - .to - .map(|v| v.into_iter().map(|v| v.into_owned()).collect()), - cc: self - .cc - .map(|v| v.into_iter().map(|v| v.into_owned()).collect()), - bcc: self - .bcc - .map(|v| v.into_iter().map(|v| v.into_owned()).collect()), - in_reply_to: self.in_reply_to.map(to_owned_cow), - message_id: self.message_id.map(to_owned_cow), - } - } -} - -#[derive(Debug, Eq, PartialEq)] -pub struct Address<'a> { - pub name: Option>, - pub adl: Option>, - pub mailbox: Option>, - pub host: Option>, -} - -impl<'a> Address<'a> { - pub fn into_owned(self) -> Address<'static> { - Address { - name: self.name.map(to_owned_cow), - adl: self.adl.map(to_owned_cow), - mailbox: self.mailbox.map(to_owned_cow), - host: self.host.map(to_owned_cow), - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RequestId(pub String); - -impl RequestId { - pub fn as_bytes(&self) -> &[u8] { - self.0.as_bytes() - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum State { - NotAuthenticated, - Authenticated, - Selected, - Logout, -} - -// Body Structure - -pub struct BodyFields<'a> { - pub param: BodyParams<'a>, - pub id: Option>, - pub description: Option>, - pub transfer_encoding: ContentEncoding<'a>, - pub octets: u32, -} - -impl<'a> BodyFields<'a> { - pub fn into_owned(self) -> BodyFields<'static> { - BodyFields { - param: body_param_owned(self.param), - id: self.id.map(to_owned_cow), - description: self.description.map(to_owned_cow), - transfer_encoding: self.transfer_encoding.into_owned(), - octets: self.octets, - } - } -} - -pub struct BodyExt1Part<'a> { - pub md5: Option>, - pub disposition: Option>, - pub language: Option>>, - pub location: Option>, - pub extension: Option>, -} - -impl<'a> BodyExt1Part<'a> { - pub fn into_owned(self) -> BodyExt1Part<'static> { - BodyExt1Part { - md5: self.md5.map(to_owned_cow), - disposition: self.disposition.map(|v| v.into_owned()), - language: self - .language - .map(|v| v.into_iter().map(to_owned_cow).collect()), - location: self.location.map(to_owned_cow), - extension: self.extension.map(|v| v.into_owned()), - } - } -} - -pub struct BodyExtMPart<'a> { - pub param: BodyParams<'a>, - pub disposition: Option>, - pub language: Option>>, - pub location: Option>, - pub extension: Option>, -} - -impl<'a> BodyExtMPart<'a> { - pub fn into_owned(self) -> BodyExtMPart<'static> { - BodyExtMPart { - param: body_param_owned(self.param), - disposition: self.disposition.map(|v| v.into_owned()), - language: self - .language - .map(|v| v.into_iter().map(to_owned_cow).collect()), - location: self.location.map(to_owned_cow), - extension: self.extension.map(|v| v.into_owned()), - } - } -} - -/// The name attributes are returned as part of a LIST response described in -/// [RFC 3501 section 7.2.2](https://tools.ietf.org/html/rfc3501#section-7.2.2). -/// -/// This enumeration additional includes values from the extension Special-Use -/// Mailboxes [RFC 6154 section 2](https://tools.ietf.org/html/rfc6154#section-2). -#[derive(Debug, Eq, PartialEq, Clone)] -#[non_exhaustive] -pub enum NameAttribute<'a> { - /// From [RFC 3501 section 7.2.2](https://tools.ietf.org/html/rfc3501#section-7.2.2): - /// - /// > It is not possible for any child levels of hierarchy to exist - /// > under this name; no child levels exist now and none can be - /// > created in the future. - NoInferiors, - /// From [RFC 3501 section 7.2.2](https://tools.ietf.org/html/rfc3501#section-7.2.2): - /// - /// > It is not possible to use this name as a selectable mailbox. - NoSelect, - /// From [RFC 3501 section 7.2.2](https://tools.ietf.org/html/rfc3501#section-7.2.2): - /// - /// > The mailbox has been marked "interesting" by the server; the - /// > mailbox probably contains messages that have been added since - /// > the last time the mailbox was selected. - Marked, - /// From [RFC 3501 section 7.2.2](https://tools.ietf.org/html/rfc3501#section-7.2.2): - /// - /// > The mailbox does not contain any additional messages since the - /// > last time the mailbox was selected. - Unmarked, - /// From [RFC 6154 section 2](https://tools.ietf.org/html/rfc6154#section-2): - /// - /// > This mailbox presents all messages in the user's message store. - /// > Implementations MAY omit some messages, such as, perhaps, those - /// > in \Trash and \Junk. When this special use is supported, it is - /// > almost certain to represent a virtual mailbox. - All, - /// From [RFC 6154 section 2](https://tools.ietf.org/html/rfc6154#section-2): - /// - /// > This mailbox is used to archive messages. The meaning of an - /// > "archival" mailbox is server-dependent; typically, it will be - /// > used to get messages out of the inbox, or otherwise keep them - /// > out of the user's way, while still making them accessible. - Archive, - /// From [RFC 6154 section 2](https://tools.ietf.org/html/rfc6154#section-2): - /// - /// > This mailbox is used to hold draft messages -- typically, - /// > messages that are being composed but have not yet been sent. In - /// > some server implementations, this might be a virtual mailbox, - /// > containing messages from other mailboxes that are marked with - /// > the "\Draft" message flag. Alternatively, this might just be - /// > advice that a client put drafts here. - Drafts, - /// From [RFC 6154 section 2](https://tools.ietf.org/html/rfc6154#section-2): - /// - /// > This mailbox presents all messages marked in some way as - /// > "important". When this special use is supported, it is likely - /// > to represent a virtual mailbox collecting messages (from other - /// > mailboxes) that are marked with the "\Flagged" message flag. - Flagged, - /// From [RFC 6154 section 2](https://tools.ietf.org/html/rfc6154#section-2): - /// - /// > This mailbox is where messages deemed to be junk mail are held. - /// > Some server implementations might put messages here - /// > automatically. Alternatively, this might just be advice to a - /// > client-side spam filter. - Junk, - /// From [RFC 6154 section 2](https://tools.ietf.org/html/rfc6154#section-2): - /// - /// > This mailbox is used to hold copies of messages that have been - /// > sent. Some server implementations might put messages here - /// > automatically. Alternatively, this might just be advice that a - /// > client save sent messages here. - Sent, - /// From [RFC 6154 section 2](https://tools.ietf.org/html/rfc6154#section-2) - /// - /// > This mailbox is used to hold messages that have been deleted or - /// > marked for deletion. In some server implementations, this might - /// > be a virtual mailbox, containing messages from other mailboxes - /// > that are marked with the "\Deleted" message flag. - /// > Alternatively, this might just be advice that a client that - /// > chooses not to use the IMAP "\Deleted" model should use this as - /// > its trash location. In server implementations that strictly - /// > expect the IMAP "\Deleted" model, this special use is likely not - /// > to be supported. - Trash, - /// A name attribute not defined in [RFC 3501 section 7.2.2](https://tools.ietf.org/html/rfc3501#section-7.2.2) - /// or any supported extension. - Extension(Cow<'a, str>), -} - -impl<'a> NameAttribute<'a> { - pub fn into_owned(self) -> NameAttribute<'static> { - match self { - // RFC 3501 - NameAttribute::NoInferiors => NameAttribute::NoInferiors, - NameAttribute::NoSelect => NameAttribute::NoSelect, - NameAttribute::Marked => NameAttribute::Marked, - NameAttribute::Unmarked => NameAttribute::Unmarked, - // RFC 6154 - NameAttribute::All => NameAttribute::All, - NameAttribute::Archive => NameAttribute::Archive, - NameAttribute::Drafts => NameAttribute::Drafts, - NameAttribute::Flagged => NameAttribute::Flagged, - NameAttribute::Junk => NameAttribute::Junk, - NameAttribute::Sent => NameAttribute::Sent, - NameAttribute::Trash => NameAttribute::Trash, - // Extensions not supported by this crate - NameAttribute::Extension(s) => NameAttribute::Extension(to_owned_cow(s)), - } - } -} - -// IMAP4 QUOTA extension (rfc2087) - -/// https://tools.ietf.org/html/rfc2087#section-3 -#[derive(Debug, Eq, PartialEq, Hash, Clone)] -pub enum QuotaResourceName<'a> { - /// Sum of messages' RFC822.SIZE, in units of 1024 octets - Storage, - /// Number of messages - Message, - Atom(Cow<'a, str>), -} - -impl<'a> QuotaResourceName<'a> { - pub fn into_owned(self) -> QuotaResourceName<'static> { - match self { - QuotaResourceName::Message => QuotaResourceName::Message, - QuotaResourceName::Storage => QuotaResourceName::Storage, - QuotaResourceName::Atom(v) => QuotaResourceName::Atom(to_owned_cow(v)), - } - } -} - -/// 5.1. QUOTA Response (https://tools.ietf.org/html/rfc2087#section-5.1) -#[derive(Debug, Eq, PartialEq, Hash, Clone)] -pub struct QuotaResource<'a> { - pub name: QuotaResourceName<'a>, - /// current usage of the resource - pub usage: u64, - /// resource limit - pub limit: u64, -} - -impl<'a> QuotaResource<'a> { - pub fn into_owned(self) -> QuotaResource<'static> { - QuotaResource { - name: self.name.into_owned(), - usage: self.usage, - limit: self.limit, - } - } -} - -/// 5.1. QUOTA Response (https://tools.ietf.org/html/rfc2087#section-5.1) -#[derive(Debug, Eq, PartialEq, Hash, Clone)] -pub struct Quota<'a> { - /// quota root name - pub root_name: Cow<'a, str>, - pub resources: Vec>, -} - -impl<'a> Quota<'a> { - pub fn into_owned(self) -> Quota<'static> { - Quota { - root_name: to_owned_cow(self.root_name), - resources: self.resources.into_iter().map(|r| r.into_owned()).collect(), - } - } -} - -/// 5.2. QUOTAROOT Response (https://tools.ietf.org/html/rfc2087#section-5.2) -#[derive(Debug, Eq, PartialEq, Hash, Clone)] -pub struct QuotaRoot<'a> { - /// mailbox name - pub mailbox_name: Cow<'a, str>, - /// zero or more quota root names - pub quota_root_names: Vec>, -} - -impl<'a> QuotaRoot<'a> { - pub fn into_owned(self) -> QuotaRoot<'static> { - QuotaRoot { - mailbox_name: to_owned_cow(self.mailbox_name), - quota_root_names: self - .quota_root_names - .into_iter() - .map(to_owned_cow) - .collect(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Tests that the [`NameAttribute::into_owned`] method returns the - /// same value (the ownership should only change). - #[test] - fn test_name_attribute_into_owned() { - let name_attributes = [ - // RFC 3501 - NameAttribute::NoInferiors, - NameAttribute::NoSelect, - NameAttribute::Marked, - NameAttribute::Unmarked, - // RFC 6154 - NameAttribute::All, - NameAttribute::Archive, - NameAttribute::Drafts, - NameAttribute::Flagged, - NameAttribute::Junk, - NameAttribute::Sent, - NameAttribute::Trash, - // Extensions not supported by this crate - NameAttribute::Extension(Cow::Borrowed("Foobar")), - ]; - - for name_attribute in name_attributes { - let owned_name_attribute = name_attribute.clone().into_owned(); - assert_eq!(name_attribute, owned_name_attribute); - } - } -} From be8ff095c51592e9f8255fe23a38c50a21f7be58 Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:53:28 -0700 Subject: [PATCH 03/25] updated --- CHANGELOG.md | 39 +++ MCP.md | 18 +- README.md | 16 +- docs/plans/10-07-2027_Dangaling_Bugs.md | 35 +++ src/connection.rs | 220 ++++++++++++--- src/credentials.rs | 71 ++++- src/draft.rs | 61 ++++ src/header_cache.rs | 5 +- src/imap_client.rs | 151 ++++++++-- src/lib.rs | 360 +++++++++++++++++++++--- src/mcp/args.rs | 28 +- src/mcp/mod.rs | 43 ++- src/mcp/resources.rs | 20 +- src/mcp/tools_write.rs | 2 +- src/mcp/wire.rs | 38 +++ src/mutation_journal.rs | 6 +- src/secret.rs | 78 ++++- src/types.rs | 8 +- tests/mcp_integration.rs | 53 ++++ 19 files changed, 1107 insertions(+), 145 deletions(-) create mode 100644 docs/plans/10-07-2027_Dangaling_Bugs.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 23a5ee6..b64e157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 when it was last used. Evidence only: a discovered address is not sendable until it is added as an alias. `list_accounts` now carries each account's `displayName` and `addresses`. +- **`ListIdentitiesResponse` and `SenderIdentity` implement `Deserialize`**, so + an embedder can read the `list_identities` tool's structured result back into + the library type (the wire output is asserted to round-trip). Agent Muse's + Mail Accounts settings use it to offer the Sent addresses as aliases. +- **Every recipient field documents `Name
`** — `to`, `cc`, `bcc` and + `replyTo` on both draft tools, and the handshake instructions — so agents keep + the names users write instead of stripping them to bare addresses. - **Evidence-grade RFC822 archive tools** — `download_message_source` writes one exact `BODY.PEEK[]` result directly to a create-new private file, returning its SHA-256, parsed message metadata, and a contemporaneous DNS-backed local @@ -49,6 +56,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 gains `display_name`; `AccountInfo` gains `display_name` and `addresses`. - **A distinct login is never a sending identity** unless listed in `aliases`; an alias equal to the login is now kept rather than normalized away. +- **`AGENTMAIL_CACHE_DIR` holds both caches directly** — + `/header-cache-v1.sqlite3` beside `/mutation-journal.sqlite3`, the + layout the builder's `cache_dir(dir)` already used. The header cache used to + sit one level deeper (`/agentmail/`); that copy is no longer read and + rebuilds on its own. +- **`SecretError` gains `NoEntry` and `KeyringTimedOut`** (breaking for + exhaustive matches): a missing keychain entry was `Backend(..)`. +- **The tool router is built once per process**, not on every `call_tool`, + `list_tools` and `get_tool`. - **Dependencies** — async-imap 0.11.3 → 0.12.0 (LOGIN no longer fails on a `NO` for another tag; unicode banners parse) with the vendored imap-proto rebased onto 0.17.0 (nom 8) and its UIDFETCH patch re-applied; mail-auth @@ -77,6 +93,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 absolute, canonical path, as `download_message_source` returns. - **An expired task's status watcher waited forever** — the TTL prune aborted the worker without publishing a result. Expiry now publishes a cancelled one. +- **The UID Mode walk could loop forever** on a server that ignores the UID + range, re-fetching the same page until cancelled. A page that does not move + the range down is now refused. +- **Unsolicited FETCH responses** — another client changing flags while a + fetch runs — failed `get_messages` ("message UID 0") and could add empty or + duplicate rows to the ranking projection. Only the requested UIDs, each with + its fetched section, are kept. +- **The keepalive could open a second connection** on one-connection + providers (Yahoo/AOL): it took idle sessions out of the pool without a + connection permit, so an acquire during the ping LOGINed anew. It now pings + under a permit and skips accounts whose connections are all busy. +- **A locked keychain** could stall every connect for the account (the read + had no deadline and runs under the connect lock), and without a configured + `password` it was reported as "No password found". Keychain calls now give + up after 30 s with a reason, and only a genuinely absent entry reads as + missing. +- **Attachments named past a filesystem's 255-byte limit could not be + downloaded** ("File name too long"). The canonical `{uid}_{index}_{name}` is + now shortened to 240 bytes, its extension kept and never split mid-character, + and `/info` advertises exactly that name. +- **A `download_attachments` failure partway left files behind**, unreported. + The download is now all or nothing: every name is checked before writing, + and a failure removes what was already written. - **`configure` wrote Rust escapes into TOML** (`\u{301}` for a decomposed accent), producing a config file that failed to parse; every string is now written by the TOML serializer. diff --git a/MCP.md b/MCP.md index f2915a1..9c1cd24 100644 --- a/MCP.md +++ b/MCP.md @@ -291,7 +291,7 @@ server identities from colliding, but deliberately excludes the local account display name so renaming an account reuses the same projection. SQLite uses WAL mode with `synchronous=NORMAL`. Set `AGENTMAIL_DISABLE_HEADER_CACHE=1` to disable it or `AGENTMAIL_CACHE_DIR` to -override its root; cache errors fall back to live IMAP. +keep it directly in another directory; cache errors fall back to live IMAP. `list_flags` and `find_attachments` use the same discovery plan. Discovery uses one selectable `\All` mailbox exclusively when available. Enumerated fallback and account-wide destructive tools skip `\All`, `\Drafts`, `\Flagged`, `\Important`, `\Junk`, and `\Trash`, while retaining storage roles including `\Archive`, `\Sent`, `\Memos`, `\Scheduled`, and `\Snoozed`. A caller-provided mailbox bypasses planning and is honored directly. IMAP defines `\NoSelect`, not a separate `\NoScan` attribute; `\NoSelect` is always excluded automatically. @@ -575,6 +575,12 @@ reports success only when the resulting state is unambiguous. in its `To`, then `Cc`. 3. Otherwise the primary identity. +**Recipients.** `to`, `cc`, `bcc` and `replyTo` take the same forms as `from`: +a bare address or `Name
`, one recipient per entry. A name with a +comma or other punctuation can be quoted (`"Blake, Mark" `) +or written plainly (`Blake, Mark `); two recipients in one +entry are refused rather than guessed apart. + An account with no identity at all (an opaque login and no `email`) is refused with what to configure. The result's `from` is the sender actually written — resolved, not echoed — formatted so it parses back as one mailbox (a name with a @@ -668,6 +674,11 @@ A replaced draft always has a NEW UID. Use the one this returns. "downloaded": [{ "index", "filename", "path", "contentType", "size" }] } ``` +`path` is absolute. The download is all or nothing: every target name is +checked before the first byte is written (an existing file is refused, never +overwritten), and a failure partway removes the files already written, so a +retry starts clean. + **download_message_source** ```json @@ -890,8 +901,9 @@ Each inventory entry carries `index`, the original `name` (omitted for nameless parts), the canonical `filename`, `contentType`, `size`, an optional `contentId`, and the part's `resourceUri`. Attachments follow one naming nomenclature everywhere: the canonical filename is -`{uid}_{index}_{sanitized-name}` (`unnamed` for nameless parts) — exactly what -`download_attachments` writes to disk. `/attachments/{index}` (zero-based part +`{uid}_{index}_{sanitized-name}` (`unnamed` for nameless parts), shortened to +240 bytes with its extension kept when the original name is longer than a +filesystem allows — exactly what `download_attachments` writes to disk. `/attachments/{index}` (zero-based part index, stable within a UIDVALIDITY epoch) returns the part as an MCP resource `blob` served with the part's own content type; parts above 4 MiB fail with guidance to use the `download_attachments` tool instead. diff --git a/README.md b/README.md index d88a582..5d50dc7 100644 --- a/README.md +++ b/README.md @@ -609,10 +609,14 @@ optional [task-based invocation](https://modelcontextprotocol.io/specification/2 ### Ranking cache privacy and configuration The ranking cache defaults to -`dirs::cache_dir()/agentmail/header-cache-v1.sqlite3`. Set -`AGENTMAIL_CACHE_DIR` to override the cache root, or set -`AGENTMAIL_DISABLE_HEADER_CACHE=1` (`true` and `yes` also work) to use live scans -only. SQLite failures automatically fall back to live IMAP behavior. +`dirs::cache_dir()/agentmail/header-cache-v1.sqlite3`. Set `AGENTMAIL_CACHE_DIR` +to keep it and the recovery journal directly in another directory — +`/header-cache-v1.sqlite3` and `/mutation-journal.sqlite3`, the same +layout as the builder's `cache_dir(dir)` (before 0.7 the header cache went one +level deeper, in `/agentmail/`; that copy is no longer read, rebuilds on its +own, and can be deleted). Set `AGENTMAIL_DISABLE_HEADER_CACHE=1` (`true` and +`yes` also work) to use live scans only. SQLite failures automatically fall back +to live IMAP behavior. Embedding applications configure the same knobs programmatically — explicit builder settings override the environment variables: @@ -685,8 +689,8 @@ agentmail reconcile-moves --account gmail --operation-id # Omit --operation-id to reconcile every pending operation for the account. ``` -The recovery database lives beside the ranking cache (or under -`AGENTMAIL_CACHE_DIR`), uses `synchronous=FULL`, and has owner-only permissions +The recovery database lives beside the ranking cache (both directly in +`AGENTMAIL_CACHE_DIR` when it is set), uses `synchronous=FULL`, and has owner-only permissions on Unix. It remains enabled when header-cache persistence is disabled because mutation intent is not disposable. Reconciliation repeats COPY only when unchanged destination `UIDNEXT` proves the ambiguous attempt created nothing. diff --git a/docs/plans/10-07-2027_Dangaling_Bugs.md b/docs/plans/10-07-2027_Dangaling_Bugs.md new file mode 100644 index 0000000..1d61605 --- /dev/null +++ b/docs/plans/10-07-2027_Dangaling_Bugs.md @@ -0,0 +1,35 @@ +# 10-07-2027_Dangaling_Bugs + +Here are the 16 Part 5 findings, each re-checked against today's code, with an effort level. All 16 still hold, and two came out different on the re-check (see below the table). + +**Effort scale:** XS = up to 2 hours · S = about half a day · M = 1–2 days · L = 3+ days. Each includes tests. + +| # | Sev | Finding | Where | Effort | What drives it | Status | +|---|---|---|---|---|---|---| +| 1 | Med | Account-wide scans keep using a session after a connection error, waiting the app's 120 s timeout per remaining folder, and still return `Ok` | `lib.rs:2489`, `:2504`, `:2757`, `:3026`, `:4646` | M | Four sites. Two scan outputs need a new skipped-mailboxes field to report the gap. | +| 2 | **High** | Any SELECT failure during move reconciliation becomes a permanent `NeedsAttention`, and nothing can clear it. A BYE right after SELECT can mark a move Complete while the source copy survives. | `imap_client.rs:2892`, `:2918` | L | Error classification (S) and a search that must see its own tagged OK (S–M), plus a new tool or argument to clear stuck moves (M). | +| 3 | Med | Login retries `AUTHENTICATIONFAILED` 3 times, even on Yahoo/AOL | `imap_client.rs:444`, `:612` | XS | The code change is tiny. Whether Yahoo sends transient auth failures needs a decision or a real-account check. | +| 4 | Med | Cancelling a task aborts it mid-mutation and loses the partial counts | `mcp/tasks.rs:353` | L | Cooperative stop with a grace period, sweeps returning partial totals instead of an error, and a `cancelled` field in outputs. | +| 5 | Low-Med | An unsolicited `FETCH (FLAGS…)` becomes a UID-0 row, which fails the page or adds a bogus one | `imap_client.rs:2021`, `:2219` | S | Keep only requested UIDs, and audit the 4 other fetch loops. | Done | +| 6 | Low-Med | `ENABLE UIDONLY` is re-sent on a session already in UID mode or after SELECT; a strict server's BAD fails the whole sweep | `lib.rs:1073`, `:2416` | S | Skip ENABLE on UID-mode sessions and enable only on fresh ones. A Yahoo/AOL probe should confirm. | +| 7 | Low | Keepalive pings outside the pool permit, so a concurrent acquire can open a second Yahoo login | `connection.rs:426` | S | Take the account permit before pinging; the concurrency test is the fiddly part. | Done | +| 8 | Low | The keyring read has no deadline and runs under the connect lock; a locked keychain can stall every connect or read as "no password" | `credentials.rs:22-35`, `secret.rs:265`, `connection.rs:711-743` | S | Timeout plus a distinct "keychain locked" error. | Done| +| 9 | Low | The UID-mode walk has no progress guard; a server that ignores the range loops until cancelled | `imap_client.rs:519-563` | XS | Require the upper bound to fall each page, plus an iteration cap. | Done A server that ignores the range used to loop until the 5 s timeout; now it errors after one repeat. | +| 10 | Low | File sandbox: (a) recreates a missing (unmounted) workspace; (b) checks a path, then uses it later (race); (c) errors name stale roots | `mcp/file_access.rs:94`, `:177-233`, `:161` | S for (a)+(c), L for (b) | The race fix means directory-handle-relative I/O (cap-std or `openat`) across every download, archive and attachment path. | +| 11 | Low | Resources: (a) library size refusals surface as `internal_error`; (b) one attachment fetches and decodes the whole message; (c) each `uidValidity` completion costs a STATUS and can starve a 1-connection pool | `mcp/resources.rs:563`, `:791`, `:921` | S + M + S | (b) is the risky one: fetching a single part must keep `/info`'s attachment numbering. | +| 12 | Low | The tool router is rebuilt on every `list_tools` **and every `call_tool`** (wider than first reported) | `mcp/mod.rs:335`, `:366`, `:389` | XS | Build once, then patch the account enum per call. | Done The same tool fetched on two threads came back with different schema copies. A second test runs two servers with different accounts to check neither sees the other's accounts. | +| 13 | Low | A failed `download_attachments` doesn't report files already written | `lib.rs:6471` | S | Pre-check every target name first, and clean up or report on a mid-write failure. | Done | +| 14 | Low | `AGENTMAIL_CACHE_DIR` means `/agentmail/` for the header cache but `/` for the move journal | `header_cache.rs:1394` vs `mutation_journal.rs:117` | XS | Move the header cache (a one-time rebuild), never the journal: pending moves live there. | Done A layout test covering both files, with and without the variable. | +| 15 | Upstream | async-imap 0.12 still swallows a tagged NO/BAD after SEARCH/FETCH | async-imap `parse.rs` | M | Carry a fork like the imap-proto one, plus an upstream PR. Filing the issue alone is XS. | +| 16 | Docs | CHANGELOG has no 0.5/0.6 sections; ARCHITECTURE.md points at a nonexistent path | `CHANGELOG.md`, parent `ARCHITECTURE.md:266` | XS + S | There are no v0.5.0/v0.6.0 git tags, so the release boundaries have to be reconstructed from history. | Done + +**Changed on re-check:** +- **#2 is worse than reported, so I've raised it to High.** A stuck move also blocks renaming or deleting both its source and destination mailboxes. The only remedy offered is "reconcile that operation", and reconcile returns `NeedsAttention` immediately. So one transient connection drop can lock two mailboxes for good. +- **#3 is narrower.** A `[LIMIT]` reply itself is already exempt from retry; only the auth-failure retries remain. + +**Suggested order:** +1. The quick wins: #9, #12, #14, the ARCHITECTURE.md path, #5, #7, #8, #13. +2. #2, for data integrity and the permanent block. +3. #1. +4. #6 and #3 once you've checked Yahoo/AOL behaviour. +5. The larger items: #4, #10b, #11b, #15. \ No newline at end of file diff --git a/src/connection.rs b/src/connection.rs index 9819eba..5d7603e 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -192,6 +192,78 @@ fn idle_is_fresh(idle_for: Option, max_idle: Duration) -> bool { idle_for.is_some_and(|idle_for| idle_for < max_idle) } +/// One store of idle sessions, keyed by account. +type IdleStore = Arc>>>; + +/// One keepalive pass over the idle stores: NOOP every idle session OUTSIDE +/// the store lock (an acquiring caller must never wait behind a slow +/// dead-socket ping) and return the survivors with refreshed idle stamps. A +/// failed ping drops that session; the next acquire reconnects fresh. +/// +/// A session leaves the store only with one of its account's connection +/// permits, held until the ping ends. Without it an acquire during the ping +/// took a permit, found the store empty and LOGINed a second connection — on a +/// one-connection provider (Yahoo/AOL) the very login that trips `[LIMIT]`. With +/// the permit held that acquire waits, and gets the pinged session back. No +/// permit free means every connection is busy with real work; there is nothing +/// idle worth pinging, so the account is skipped until the next tick. +async fn keepalive_tick( + stores: &[IdleStore], + semaphores: &Mutex>>, + stats: &PoolStats, +) { + for store in stores { + let accounts: Vec = store.lock().await.keys().cloned().collect(); + for account in accounts { + // Every pooled session was released after an acquire, which + // created the account's semaphore — none means nothing to ping. + let Some(semaphore) = semaphores.lock().await.get(&account).cloned() else { + continue; + }; + let mut permits = Vec::new(); + let mut idle_set = Vec::new(); + loop { + let Ok(permit) = Arc::clone(&semaphore).try_acquire_owned() else { + break; + }; + let Some(idle) = store.lock().await.get_mut(&account).and_then(Vec::pop) else { + break; + }; + permits.push(permit); + idle_set.push(idle); + } + if idle_set.is_empty() { + continue; + } + let mut alive = Vec::with_capacity(idle_set.len()); + for mut idle in idle_set { + if imap_client::ping(&mut idle.session).await.is_ok() { + idle.idle_since = SystemTime::now(); + alive.push(idle); + stats.keepalive_pings.fetch_add(1, Ordering::Relaxed); + } else { + // dead — dropped; the next acquire reconnects. + stats.keepalive_drops.fetch_add(1, Ordering::Relaxed); + tracing::debug!( + account = %account, + "keepalive: dropped a dead idle session; next acquire reconnects", + ); + } + } + if !alive.is_empty() { + tracing::debug!( + account = %account, + held = alive.len(), + "keepalive: kept idle session(s) alive", + ); + store.lock().await.entry(account).or_default().extend(alive); + } + // Released only now that the sessions are back in the store. + drop(permits); + } + } +} + /// Ceiling for the escalating login cooldown. One hour outlasts observed /// AOL/Yahoo LOGIN penalty windows; doubling past it would only strand a /// recovered account. @@ -414,15 +486,15 @@ impl ConnectionPool { } /// Spawn the keepalive task once, if configured. Called from the async - /// acquire paths so a Tokio runtime is guaranteed. Each tick drains every - /// idle session — Limited pool and UID-Mode pool alike — NOOPs each - /// OUTSIDE the pool lock (an acquiring caller must never wait behind a - /// slow dead-socket ping), and returns survivors with refreshed idle - /// stamps, so they never cross the `max_idle` threshold and the server - /// never sees them as idle. This is what makes the process behave like a - /// mainstream mail client: a few long-lived connections, each LOGINed - /// once, instead of a login per gap in traffic. A failed ping drops that - /// session; the next acquire reconnects fresh. + /// acquire paths so a Tokio runtime is guaranteed. Each tick + /// ([`keepalive_tick`]) NOOPs every idle session — Limited pool and + /// UID-Mode pool alike — under a connection permit and outside the pool + /// lock, and returns survivors with refreshed idle stamps, so they never + /// cross the `max_idle` threshold and the server never sees them as idle. + /// This is what makes the process behave like a mainstream mail client: a + /// few long-lived connections, each LOGINed once, instead of a login per + /// gap in traffic. A failed ping drops that session; the next acquire + /// reconnects fresh. fn ensure_keepalive(&self) { let Some(interval) = self.keepalive else { return; @@ -432,48 +504,14 @@ impl ConnectionPool { return; } let stores = [Arc::clone(&self.pools), Arc::clone(&self.uid_pools)]; + let semaphores = Arc::clone(&self.semaphores); let stats = Arc::clone(&self.stats); *slot = Some(tokio::spawn(async move { let mut ticker = tokio::time::interval(interval); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { ticker.tick().await; - for store in &stores { - let accounts: Vec = store.lock().await.keys().cloned().collect(); - for account in accounts { - // Take the whole idle set while locked; ping outside - // the lock; return survivors. - let idle_set = store - .lock() - .await - .get_mut(&account) - .map(std::mem::take) - .unwrap_or_default(); - let mut alive = Vec::with_capacity(idle_set.len()); - for mut idle in idle_set { - if imap_client::ping(&mut idle.session).await.is_ok() { - idle.idle_since = SystemTime::now(); - alive.push(idle); - stats.keepalive_pings.fetch_add(1, Ordering::Relaxed); - } else { - // dead — dropped; the next acquire reconnects. - stats.keepalive_drops.fetch_add(1, Ordering::Relaxed); - tracing::debug!( - account = %account, - "keepalive: dropped a dead idle session; next acquire reconnects", - ); - } - } - if !alive.is_empty() { - tracing::debug!( - account = %account, - held = alive.len(), - "keepalive: kept idle session(s) alive", - ); - store.lock().await.entry(account).or_default().extend(alive); - } - } - } + keepalive_tick(&stores, &semaphores, &stats).await; } })); } @@ -1076,6 +1114,98 @@ mod tests { ); } + /// One account's idle store holding `session`, and a one-permit + /// semaphore for it — a Yahoo/AOL account's shape. + async fn one_idle_session( + session: ImapSession, + ) -> ( + IdleStore, + Arc, + Mutex>>, + ) { + let store: IdleStore = Arc::new(Mutex::new(HashMap::new())); + store.lock().await.insert( + "aol".to_string(), + vec![IdleSession { + session, + idle_since: SystemTime::now(), + }], + ); + let semaphore = Arc::new(Semaphore::new(1)); + let mut semaphores = HashMap::new(); + semaphores.insert("aol".to_string(), Arc::clone(&semaphore)); + (store, semaphore, Mutex::new(semaphores)) + } + + /// With every permit held by in-flight work there is nothing idle to keep + /// alive: taking the session out anyway is what let a concurrent acquire + /// find the store empty and LOGIN a second connection. + #[tokio::test] + async fn keepalive_takes_no_session_while_every_permit_is_in_use() { + let (session, server) = imap_client::test_support::scripted_session(|_, _| None).await; + let (store, semaphore, semaphores) = one_idle_session(session).await; + let stats = PoolStats::default(); + let _in_flight = Arc::clone(&semaphore) + .try_acquire_owned() + .expect("the only permit"); + + keepalive_tick(&[Arc::clone(&store)], &semaphores, &stats).await; + + assert_eq!( + store.lock().await.get("aol").map(Vec::len), + Some(1), + "the idle session stays in the store" + ); + assert_eq!(stats.keepalive_pings.load(Ordering::Relaxed), 0); + assert_eq!(stats.keepalive_drops.load(Ordering::Relaxed), 0); + drop(store); + let commands = server.commands().await; + assert!(!commands.iter().any(|c| c.contains("NOOP")), "{commands:?}"); + } + + /// A session out of the store for its ping is covered by a permit for the + /// whole ping, so an acquire in that window waits for the session to come + /// back instead of opening another connection; both return afterwards. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn keepalive_holds_a_permit_for_the_session_it_is_pinging() { + let (noop_seen, noop_arrived) = std::sync::mpsc::channel::<()>(); + let (release_reply, reply_released) = std::sync::mpsc::channel::<()>(); + let (session, server) = imap_client::test_support::scripted_session(move |tag, command| { + command.contains(" NOOP").then(|| { + // Hold the reply until the test has looked at the permit. + noop_seen.send(()).expect("test listening"); + reply_released.recv().expect("test releases the reply"); + format!("{tag} OK NOOP completed\r\n") + }) + }) + .await; + let (store, semaphore, semaphores) = one_idle_session(session).await; + let stats = Arc::new(PoolStats::default()); + + let tick = tokio::spawn({ + let store = Arc::clone(&store); + let stats = Arc::clone(&stats); + async move { keepalive_tick(&[store], &semaphores, &stats).await } + }); + tokio::task::spawn_blocking(move || noop_arrived.recv()) + .await + .expect("join") + .expect("the NOOP reached the server"); + assert_eq!( + semaphore.available_permits(), + 0, + "the session being pinged holds the account's permit" + ); + release_reply.send(()).expect("server waiting"); + tick.await.expect("tick"); + + assert_eq!(semaphore.available_permits(), 1, "and gives it back"); + assert_eq!(store.lock().await.get("aol").map(Vec::len), Some(1)); + assert_eq!(stats.keepalive_pings.load(Ordering::Relaxed), 1); + drop(store); + server.commands().await; + } + /// The keepalive task spawns once on first pool use when configured, and /// never spawns when it is not. #[tokio::test] diff --git a/src/credentials.rs b/src/credentials.rs index b5ca561..0dadf73 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -1,5 +1,5 @@ use crate::config::AccountConfig; -use crate::secret::Secret; +use crate::secret::{Secret, SecretError}; /// Retrieve the password for an IMAP account. /// @@ -31,8 +31,18 @@ pub async fn get_password(account_name: &str, config: &AccountConfig) -> crate:: // 3. Default keyring fallback (backward compat: handles passwords stored via set-password // before the config had a password field) let default_secret = Secret::new_keyring(format!("mail.{}", config.username)); - if let Ok(pw) = default_secret.get().await { - return Ok(pw); + match default_secret.get().await { + Ok(pw) => return Ok(pw), + // Nothing stored under that name: explain how to configure one, below. + Err(SecretError::NoEntry) => {} + // The keychain failed to answer — locked after sleep, inaccessible in + // this context, timed out. That is not a missing password, and saying + // so sends the user to re-enter one that exists. + Err(error) => { + return Err(crate::AgentmailError::Credential(format!( + "Could not read the keychain for account '{account_name}': {error}" + ))); + } } Err(crate::AgentmailError::Credential(format!( @@ -67,3 +77,58 @@ pub async fn set_password( })?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// An account with no `password` in its config, so its password comes from + /// the default keychain entry `mail.`. + fn keychain_only(username: &str) -> AccountConfig { + AccountConfig { + password: None, + ..AccountConfig::new("imap.example.com", username) + } + } + + /// A keychain that fails to answer — locked after sleep, inaccessible — is + /// not a missing password: saying so sends the user to re-enter a password + /// that exists. + #[tokio::test] + async fn a_failing_keychain_is_not_reported_as_a_missing_password() { + crate::secret::install_mock_keyring(); + let entry = + keyring_core::Entry::new(crate::secret::service_name(), "mail.locked@example.com") + .expect("mock entry"); + entry + .as_any() + .downcast_ref::() + .expect("mock credential") + .set_error(keyring_core::Error::NoStorageAccess( + "errSecInteractionNotAllowed -25308".into(), + )); + + let error = get_password("locked", &keychain_only("locked@example.com")) + .await + .expect_err("the keychain failed") + .to_string(); + + assert!(!error.contains("No password found"), "{error}"); + assert!( + error.contains("-25308"), + "the keychain's own reason reaches the user: {error}" + ); + } + + #[tokio::test] + async fn an_absent_keychain_entry_still_explains_how_to_configure_one() { + crate::secret::install_mock_keyring(); + + let error = get_password("nobody", &keychain_only("nobody@example.com")) + .await + .expect_err("nothing stored") + .to_string(); + + assert!(error.contains("No password found"), "{error}"); + } +} diff --git a/src/draft.rs b/src/draft.rs index fa72b25..2762ea4 100644 --- a/src/draft.rs +++ b/src/draft.rs @@ -1015,6 +1015,67 @@ mod tests { ); } + /// Every recipient field takes the same forms `from` does — a bare + /// address, `Name
`, a quoted name, a non-ASCII name, and an + /// unquoted `Last, First
` — and each name survives to the header. + #[test] + fn recipients_take_a_name_in_every_address_field() { + let raw = compose_draft_with_headers( + "s", + "b", + &[ + "Ann Example ".to_string(), + "plain@example.com".to_string(), + ], + &["\"Blake, Mark\" ".to_string()], + &["Blake, Jo ".to_string()], + Some(&me()), + &[], + DraftHeaderOptions { + reply_to: &["José Núñez ".to_string()], + in_reply_to: None, + references: &[], + apple_uuid: uuid::Uuid::nil(), + body_format: BodyFormat::PlainOnly, + }, + ) + .expect("named recipients compose"); + let parsed = parse(&raw); + let mailboxes = |header: Option<&mail_parser::Address<'_>>| -> Vec<(String, String)> { + header + .expect("header present") + .iter() + .map(|addr| { + ( + addr.name.as_deref().unwrap_or("").to_string(), + addr.address.as_deref().unwrap_or("").to_string(), + ) + }) + .collect() + }; + + assert_eq!( + mailboxes(parsed.to()), + [ + ("Ann Example".to_string(), "ann@example.com".to_string()), + (String::new(), "plain@example.com".to_string()), + ] + ); + assert_eq!( + mailboxes(parsed.cc()), + [("Blake, Mark".to_string(), "mark@example.com".to_string())] + ); + assert_eq!( + mailboxes(parsed.bcc()), + [("Blake, Jo".to_string(), "jo@example.com".to_string())], + "the unquoted Last, First form arrives as one named mailbox" + ); + assert_eq!( + mailboxes(parsed.reply_to()), + [("José Núñez".to_string(), "jose@example.com".to_string())] + ); + } + #[test] fn an_unquoted_last_first_name_parses_as_one_mailbox_and_two_mailboxes_do_not() { let mailbox = parse_mailbox("Blake, Mark ").expect("tolerated"); diff --git a/src/header_cache.rs b/src/header_cache.rs index 237215e..502f875 100644 --- a/src/header_cache.rs +++ b/src/header_cache.rs @@ -1391,10 +1391,7 @@ fn default_cache_path() -> Option { { return None; } - let root = std::env::var_os("AGENTMAIL_CACHE_DIR") - .map(PathBuf::from) - .or_else(dirs::cache_dir)?; - Some(root.join("agentmail").join(HeaderCache::FILE_NAME)) + crate::default_cache_file(HeaderCache::FILE_NAME) } fn open_connection(path: &Path) -> CacheResult { diff --git a/src/imap_client.rs b/src/imap_client.rs index c93c990..c962328 100644 --- a/src/imap_client.rs +++ b/src/imap_client.rs @@ -288,6 +288,48 @@ where .await } +/// The items of a `UID FETCH` that answer it: one per requested UID, in the +/// order the server sent them. +/// +/// Unsolicited FETCH responses — another client changing a message's flags +/// while this command runs — share the command's response stream, and +/// async-imap yields them as ordinary items. Taken at face value, one without +/// a UID became "UID 0" and failed the whole page, one for a UID nobody asked +/// for became a row of its own, and a FLAGS-only repeat of a requested message +/// became a second, empty row for it. Kept: for each requested UID, its first +/// item that carries a fetched section, else its first item. An item is never +/// dropped for lacking a section alone — a requested message whose section came +/// back `NIL` still answers, and callers that prune unanswered UIDs rely on it. +fn answering_fetches( + fetched: Vec>, + requested: &[u32], +) -> Result> { + let carries_section = |fetch: &async_imap::types::Fetch| { + fetch.header().is_some() || fetch.body().is_some() || fetch.text().is_some() + }; + let requested: hashbrown::HashSet = requested.iter().copied().collect(); + let mut answers: Vec<(u32, async_imap::types::Fetch)> = Vec::with_capacity(requested.len()); + let mut position: hashbrown::HashMap = hashbrown::HashMap::new(); + for item in fetched { + let fetch = item.map_err(AgentmailError::Imap)?; + let Some(uid) = fetch.uid.filter(|uid| requested.contains(uid)) else { + continue; + }; + match position.get(&uid) { + Some(&index) => { + if !carries_section(&answers[index].1) && carries_section(&fetch) { + answers[index].1 = fetch; + } + } + None => { + position.insert(uid, answers.len()); + answers.push((uid, fetch)); + } + } + } + Ok(answers) +} + /// Select a mailbox with timeout. Use this instead of calling `session.select()` directly. /// Yahoo/AOL intermittently answer SELECT/EXAMINE with /// `NO [SERVERBUG] ... Please try again later`. It is genuinely transient; @@ -556,9 +598,24 @@ where if page < window { break; } - // Next page covers UIDs strictly below the lowest one just seen. + // Next page covers UIDs strictly below the lowest one just seen. The + // bound must SHRINK every page: a server that ignores the range answers + // each request with the same newest window, and without this check the + // walk would re-fetch it until someone cancelled. match lowest { - Some(low) if low > 1 => upper = Some(low - 1), + Some(low) if low > 1 => { + let next = low - 1; + if let Some(previous) = upper + && next >= previous + { + return Err(AgentmailError::Parse(format!( + "UID Mode walk made no progress: the server answered UIDs 1:{previous} \ + with a page reaching down only to UID {low}; it may be ignoring the \ + requested range" + ))); + } + upper = Some(next); + } _ => break, } } @@ -1886,12 +1943,7 @@ async fn fetch_rank_header_rows( let fetched = timed_uid_fetch_collect(session, &uid_set, items).await?; - for item in fetched { - let fetch = item.map_err(AgentmailError::Imap)?; - let uid = match fetch.uid { - Some(u) => u, - None => continue, - }; + for (uid, fetch) in answering_fetches(fetched, chunk)? { let header_bytes = fetch.header().unwrap_or(&[]); let header_str = String::from_utf8_lossy(header_bytes); @@ -1965,9 +2017,7 @@ where "(UID BODY.PEEK[HEADER.FIELDS (List-Id)])", ) .await?; - for item in fetched { - let fetch = item.map_err(AgentmailError::Imap)?; - let Some(uid) = fetch.uid else { continue }; + for (uid, fetch) in answering_fetches(fetched, chunk)? { let header_str = String::from_utf8_lossy(fetch.header().unwrap_or(&[])); results.push((uid, extract_header_value(&header_str, "List-Id"))); } @@ -2013,12 +2063,7 @@ pub async fn fetch_by_uids( // Extract owned data from the IMAP fetch results so we can parse off-thread let mut raw_items: RawFetchItems = Vec::with_capacity(fetched.len()); - for item in fetched { - if item.is_err() { - debug!("FETCH item error"); - } - let fetch = item.map_err(AgentmailError::Imap)?; - let uid = fetch.uid.unwrap_or(0); + for (uid, fetch) in answering_fetches(fetched, uids)? { let size = fetch.size; if include_content && size.is_none_or(|value| value as usize > MAX_TRANSIENT_MESSAGE_BYTES) { @@ -4861,6 +4906,78 @@ mod tests { ); } + /// Another client changing flags mid-fetch makes the server interleave + /// unsolicited FETCH responses into our UID FETCH: one without a UID, one + /// for a UID nobody asked for, and a FLAGS-only repeat of the requested + /// message. Only the requested message, with its fetched header, may come + /// back. + #[tokio::test] + async fn unsolicited_fetch_responses_do_not_become_messages() { + let (mut session, server) = test_support::scripted_session(|tag, command| { + command.contains("UID FETCH").then(|| { + let header = "From: Ann \r\nSubject: Hello\r\n\r\n"; + // A FLAGS-only repeat on BOTH sides of the real answer: the + // fetched section must win whichever arrives first. + format!( + "* 3 FETCH (FLAGS (\\Seen))\r\n\ + * 1 FETCH (UID 7 FLAGS (\\Answered))\r\n\ + * 1 FETCH (UID 7 FLAGS () INTERNALDATE \"06-Oct-2026 10:00:00 +0000\" \ + RFC822.SIZE 120 BODY[HEADER] {{{}}}\r\n{header})\r\n\ + * 4 FETCH (UID 99 FLAGS (\\Flagged))\r\n\ + * 1 FETCH (UID 7 FLAGS (\\Seen))\r\n\ + {tag} OK UID FETCH completed\r\n", + header.len() + ) + }) + }) + .await; + + let messages = fetch_by_uids(&mut session, &[7], "INBOX", "work", false, false) + .await + .expect("unsolicited updates must not fail the page"); + + let rows: Vec<(u32, &str)> = messages + .iter() + .map(|message| (message.uid, message.subject.as_str())) + .collect(); + assert_eq!(rows, [(7, "Hello")]); + drop(session); + server.commands().await; + } + + /// A server that ignores the UID range answers every page with the same + /// newest window. The walk must refuse that instead of re-fetching it until + /// someone cancels — the scan would otherwise never finish. + #[tokio::test] + async fn walk_all_uids_uidmode_refuses_a_server_that_ignores_the_range() { + let (mut session, server) = test_support::scripted_session(|tag, command| { + command.contains("UID FETCH").then(|| { + format!( + "* 10 UIDFETCH (UID 10)\r\n* 9 UIDFETCH (UID 9)\r\n\ + {tag} OK UID FETCH completed\r\n" + ) + }) + }) + .await; + + let outcome = tokio::time::timeout( + Duration::from_secs(5), + walk_all_uids_uidmode(&mut session, 2, None, None), + ) + .await + .expect("the walk must end, not loop on a repeated page"); + + let error = outcome.expect_err("a page that does not shrink is refused"); + assert!(error.to_string().contains("no progress"), "{error}"); + drop(session); + let commands = server.commands().await; + assert_eq!( + commands.iter().filter(|c| c.contains("UID FETCH")).count(), + 2, + "one repeat is enough to know: {commands:?}" + ); + } + #[tokio::test] async fn uidfetch_response_is_parsed_with_synthesized_uid() { let (mut session, server) = scripted_uidfetch_session().await; diff --git a/src/lib.rs b/src/lib.rs index 6d859d8..d99b92a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,6 +65,31 @@ pub struct Agentmail { >, } +/// Where a persistent cache file lives when no [`AgentmailBuilder::cache_dir`] +/// was given: directly inside `$AGENTMAIL_CACHE_DIR` when it is set — the same +/// layout as `cache_dir(dir)` — else in `agentmail/` under the OS cache +/// directory. The header cache and the mutation journal both resolve through +/// here: they used to disagree, the header cache adding an `agentmail/` level +/// under the variable that the journal did not. +pub(crate) fn default_cache_file(file_name: &str) -> Option { + cache_file_in( + std::env::var_os("AGENTMAIL_CACHE_DIR").map(std::path::PathBuf::from), + dirs::cache_dir(), + file_name, + ) +} + +fn cache_file_in( + override_dir: Option, + os_cache_dir: Option, + file_name: &str, +) -> Option { + match override_dir { + Some(dir) => Some(dir.join(file_name)), + None => Some(os_cache_dir?.join("agentmail").join(file_name)), + } +} + /// Where the header cache lives — the builder's programmatic answer to the /// `AGENTMAIL_CACHE_DIR` / `AGENTMAIL_DISABLE_HEADER_CACHE` environment /// variables. An explicit choice overrides both variables. @@ -4829,7 +4854,8 @@ impl Agentmail { } /// Download attachments from a message to a directory. - /// Files are named `{uid}_{index}_{original_name}`. + /// Files are named `{uid}_{index}_{original_name}`, the name sanitized and + /// shortened past 240 bytes (extension kept); all or nothing on failure. pub async fn download_attachments( &self, mailbox: &str, @@ -6443,6 +6469,51 @@ pub(crate) fn sanitize_filename(name: &str) -> String { .collect() } +/// Longest filename AgentMail writes, in bytes. Most filesystems allow 255; +/// the margin covers a name that grows on disk (HFS+ stores names decomposed), +/// and it is the archive-name limit too. +const MAX_FILENAME_BYTES: usize = 240; + +/// The canonical on-disk name of one attachment part: `{uid}_{index}_{name}`, +/// the name sanitized and — past [`MAX_FILENAME_BYTES`] — its stem cut short +/// so the extension survives. The download writes it and the `/info` resource +/// advertises it, so both come from here. Unique within a message by its +/// index, so shortening two long names can never collide. +pub(crate) fn attachment_filename(uid: u32, index: usize, name: &str) -> String { + let prefix = format!("{uid}_{index}_"); + let name = sanitize_filename(name); + let budget = MAX_FILENAME_BYTES.saturating_sub(prefix.len()); + if name.len() <= budget { + return format!("{prefix}{name}"); + } + let (stem, extension) = match name.rsplit_once('.') { + Some((stem, extension)) + if !stem.is_empty() + && !extension.is_empty() + && extension.len() <= 16 + && !extension.contains(' ') => + { + (stem, Some(extension)) + } + _ => (name.as_str(), None), + }; + let mut cut = budget + .saturating_sub(extension.map_or(0, |extension| extension.len() + 1)) + .min(stem.len()); + while !stem.is_char_boundary(cut) { + cut -= 1; + } + // A trailing space or dot is not a usable filename ending on Windows. + let stem = match stem[..cut].trim_end_matches([' ', '.']) { + "" => "unnamed", + stem => stem, + }; + match extension { + Some(extension) => format!("{prefix}{stem}.{extension}"), + None => format!("{prefix}{stem}"), + } +} + /// Require a caller-supplied filename to be one portable path component. pub(crate) fn validate_plain_filename(filename: &str) -> Result<()> { use std::path::{Component, Path}; @@ -6468,11 +6539,36 @@ pub(crate) fn validate_plain_filename(filename: &str) -> Result<()> { /// Write one message's attachments into `output_dir` as `{uid}_{index}_{name}`, /// creating the directory private (0700) when it is new. Each file is /// create-new and 0600, so an existing file is refused, never overwritten. +/// +/// All or nothing: every target name is checked before the first byte is +/// written, and a failure partway removes the files this call already wrote, +/// so the caller never has to work out which attachments arrived — and a retry +/// does not trip over the survivors of the failed attempt. async fn write_attachment_files( output_dir: &std::path::Path, uid: u32, attachments: &[(String, String, Vec)], ) -> Result> { + write_attachment_files_with(output_dir, uid, attachments, |path, bytes| { + Box::pin(write_new_attachment(path, bytes)) + }) + .await +} + +/// [`write_attachment_files`] with the per-file writer passed in, so a test +/// can make one file fail partway through a download. +async fn write_attachment_files_with( + output_dir: &std::path::Path, + uid: u32, + attachments: &[(String, String, Vec)], + write: W, +) -> Result> +where + W: for<'a> Fn( + &'a std::path::Path, + &'a [u8], + ) -> futures::future::BoxFuture<'a, Result>, +{ #[cfg(unix)] let output_dir_existed = output_dir.exists(); tokio::fs::create_dir_all(output_dir).await.map_err(|e| { @@ -6497,51 +6593,112 @@ async fn write_attachment_files( })?; } - let mut downloaded = Vec::new(); - for (index, (name, content_type, bytes)) in attachments.iter().enumerate() { - let filename = format!("{}_{}_{}", uid, index, sanitize_filename(name)); - let path = output_dir.join(&filename); - let mut options = tokio::fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - options.mode(0o600); + let targets: Vec<(String, std::path::PathBuf)> = attachments + .iter() + .enumerate() + .map(|(index, (name, _, _))| { + let filename = attachment_filename(uid, index, name); + let path = output_dir.join(&filename); + (filename, path) + }) + .collect(); + for (_, path) in &targets { + // `symlink_metadata`, not `exists`: a dangling symlink is a taken name. + if tokio::fs::symlink_metadata(path).await.is_ok() { + return Err(AgentmailError::Other(format!( + "refusing to overwrite existing attachment '{}'; no attachment was written", + path.display() + ))); } - let mut file = options.open(&path).await.map_err(|error| { - if error.kind() == std::io::ErrorKind::AlreadyExists { - AgentmailError::Other(format!( - "refusing to overwrite existing attachment '{}'", - path.display() - )) - } else { - AgentmailError::Other(format!("Failed to create '{}': {error}", path.display())) + } + + let mut downloaded: Vec = Vec::with_capacity(targets.len()); + let mut written: Vec = Vec::with_capacity(targets.len()); + for (index, ((filename, path), (_, content_type, bytes))) in + targets.into_iter().zip(attachments).enumerate() + { + match write(&path, bytes).await { + Ok(canonical) => { + downloaded.push(DownloadedFile { + index, + // The absolute path, as `download_message_source` returns: + // a bare filename names nothing once the caller's working + // directory is not `output_dir`, which for the MCP server + // is always the case. + path: canonical.display().to_string(), + filename, + content_type: content_type.clone(), + size: bytes.len(), + }); + written.push(path); } - })?; + Err(error) => { + let mut left_behind = Vec::new(); + for path in &written { + if tokio::fs::remove_file(path).await.is_err() { + left_behind.push(path.display().to_string()); + } + } + let outcome = if written.is_empty() { + "no attachment was written".to_string() + } else if left_behind.is_empty() { + format!( + "the {} attachment(s) already written were removed", + written.len() + ) + } else { + format!( + "these could not be removed and must be deleted before retrying: {}", + left_behind.join(", ") + ) + }; + return Err(AgentmailError::Other(format!("{error}; {outcome}"))); + } + } + } + Ok(downloaded) +} + +/// Create `path` (create-new, 0600), write `bytes`, and return its canonical +/// path. A file this created is removed again if anything after creating it +/// fails, so an error never leaves a partial attachment behind. +async fn write_new_attachment(path: &std::path::Path, bytes: &[u8]) -> Result { + let mut options = tokio::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + options.mode(0o600); + } + let mut file = options.open(path).await.map_err(|error| { + if error.kind() == std::io::ErrorKind::AlreadyExists { + AgentmailError::Other(format!( + "refusing to overwrite existing attachment '{}'", + path.display() + )) + } else { + AgentmailError::Other(format!("Failed to create '{}': {error}", path.display())) + } + })?; + let written = async { file.write_all(bytes).await.map_err(|error| { AgentmailError::Other(format!("Failed to write '{}': {error}", path.display())) })?; file.flush().await.map_err(|error| { AgentmailError::Other(format!("Failed to flush '{}': {error}", path.display())) })?; - // The absolute path, as `download_message_source` returns: a bare - // filename names nothing once the caller's working directory is not - // `output_dir`, which for the MCP server is always the case. - let path = tokio::fs::canonicalize(&path).await.map_err(|error| { + tokio::fs::canonicalize(path).await.map_err(|error| { AgentmailError::Other(format!( "saved attachment but could not resolve '{}': {error}", path.display() )) - })?; - - downloaded.push(DownloadedFile { - index, - path: path.display().to_string(), - filename, - content_type: content_type.clone(), - size: bytes.len(), - }); + }) } - Ok(downloaded) + .await; + if written.is_err() { + drop(file); + let _ = tokio::fs::remove_file(path).await; + } + written } /// Create, durably write, and close one private file. Any ordinary write error @@ -6802,6 +6959,29 @@ mod tests { ); } + /// `AGENTMAIL_CACHE_DIR=dir` means what `cache_dir(dir)` means: both + /// files directly in `dir`. Without it, both sit in `agentmail/` under the + /// OS cache directory. + #[test] + fn both_caches_resolve_into_the_same_directory() { + let dir = std::path::PathBuf::from("/tmp/agentmail-cache-root"); + let os = std::path::PathBuf::from("/Users/me/Library/Caches"); + for file in [ + header_cache::HeaderCache::FILE_NAME, + mutation_journal::MutationJournal::FILE_NAME, + ] { + assert_eq!( + cache_file_in(Some(dir.clone()), Some(os.clone()), file), + Some(dir.join(file)) + ); + assert_eq!( + cache_file_in(None, Some(os.clone()), file), + Some(os.join("agentmail").join(file)) + ); + } + assert_eq!(cache_file_in(None, None, "x.sqlite3"), None); + } + #[test] fn reply_subject_adds_exactly_one_prefix() { assert_eq!(reply_subject("Status"), "Re: Status"); @@ -6894,6 +7074,120 @@ mod tests { tokio::fs::remove_dir_all(dir).await.expect("cleanup"); } + fn attachment(name: &str) -> (String, String, Vec) { + (name.to_string(), "text/plain".to_string(), b"data".to_vec()) + } + + async fn files_in(dir: &std::path::Path) -> Vec { + let mut names = Vec::new(); + let mut entries = tokio::fs::read_dir(dir).await.expect("dir exists"); + while let Some(entry) = entries.next_entry().await.expect("entry") { + names.push(entry.file_name().to_string_lossy().into_owned()); + } + names.sort(); + names + } + + /// An attachment named past the filesystem's 255-byte limit is still + /// saved — under a shortened name that keeps its extension. + #[tokio::test] + async fn an_attachment_with_a_very_long_name_is_saved_under_a_shortened_one() { + let dir = std::env::temp_dir().join(format!("agentmail-att-{}", uuid::Uuid::new_v4())); + let long = format!("{}.pdf", "quarterly report ".repeat(25)); + assert!(long.len() > 255); + + let written = write_attachment_files(&dir, 7, &[attachment(&long)]) + .await + .expect("a long name is shortened, not refused"); + + let filename = &written[0].filename; + assert!( + filename.len() <= 240, + "{} bytes: {filename}", + filename.len() + ); + assert!(filename.starts_with("7_0_quarterly report"), "{filename}"); + assert!(filename.ends_with(".pdf"), "{filename}"); + assert_eq!(files_in(&dir).await, std::slice::from_ref(filename)); + tokio::fs::remove_dir_all(dir).await.expect("cleanup"); + } + + /// A download that fails partway leaves nothing behind: the files it had + /// already written are removed, so a retry neither collides with them nor + /// leaves the caller guessing which attachments arrived. + #[tokio::test] + async fn a_download_that_fails_partway_removes_what_it_wrote() { + let dir = std::env::temp_dir().join(format!("agentmail-att-{}", uuid::Uuid::new_v4())); + let attachments = [attachment("first.txt"), attachment("second.txt")]; + let writes = std::sync::atomic::AtomicUsize::new(0); + + // The first file is written for real; the second fails — a full disk, + // say — after the first is already on disk. + let error = write_attachment_files_with(&dir, 7, &attachments, |path, bytes| { + let nth = writes.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Box::pin(async move { + if nth == 0 { + write_new_attachment(path, bytes).await + } else { + Err(AgentmailError::Other("No space left on device".to_string())) + } + }) + }) + .await + .expect_err("the second file cannot be written"); + + assert!(error.to_string().contains("removed"), "{error}"); + assert!(files_in(&dir).await.is_empty(), "nothing is left behind"); + tokio::fs::remove_dir_all(dir).await.expect("cleanup"); + } + + /// A shortened name stays valid UTF-8 — the cut never splits a character + /// — and keeps its extension; a short one is untouched. + #[test] + fn attachment_filenames_are_capped_on_a_character_boundary() { + let name = format!("{}.docx", "\u{e9}".repeat(200)); + let filename = attachment_filename(123, 4, &name); + assert!(filename.len() <= MAX_FILENAME_BYTES, "{}", filename.len()); + assert!(filename.starts_with("123_4_\u{e9}"), "{filename}"); + assert!(filename.ends_with(".docx"), "{filename}"); + + let no_extension = "x".repeat(400); + assert_eq!( + attachment_filename(1, 0, &no_extension).len(), + MAX_FILENAME_BYTES + ); + assert_eq!(attachment_filename(1, 0, "a/b.txt"), "1_0_a_b.txt"); + } + + /// A name already taken is refused before ANY file is written, not after + /// the attachments ahead of it. + #[tokio::test] + async fn an_existing_target_is_refused_before_anything_is_written() { + let dir = std::env::temp_dir().join(format!("agentmail-att-{}", uuid::Uuid::new_v4())); + tokio::fs::create_dir_all(&dir).await.expect("dir"); + tokio::fs::write(dir.join("7_1_second.txt"), b"mine") + .await + .expect("existing file"); + let attachments = [attachment("first.txt"), attachment("second.txt")]; + + let error = write_attachment_files(&dir, 7, &attachments) + .await + .expect_err("a taken name is refused"); + + assert!( + error.to_string().contains("refusing to overwrite"), + "{error}" + ); + assert_eq!(files_in(&dir).await, ["7_1_second.txt"]); + assert_eq!( + tokio::fs::read(dir.join("7_1_second.txt")) + .await + .expect("read"), + b"mine" + ); + tokio::fs::remove_dir_all(dir).await.expect("cleanup"); + } + #[tokio::test] async fn archive_write_is_private_and_never_overwrites() { let dir = std::env::temp_dir().join(format!("agentmail-eml-{}", uuid::Uuid::new_v4())); diff --git a/src/mcp/args.rs b/src/mcp/args.rs index 22c2412..2ab5f54 100644 --- a/src/mcp/args.rs +++ b/src/mcp/args.rs @@ -248,16 +248,24 @@ pub(super) struct CreateDraftArgs { #[schemars(description = "Draft body content.")] pub(super) body: String, #[serde(default)] - #[schemars(description = "To recipient email addresses.")] + #[schemars( + description = "To recipients, one per entry: a bare address or `Name
`. A name with a comma or other punctuation can be quoted (`\"Blake, Mark\" `) or written as is (`Blake, Mark `). Never put two recipients in one entry." + )] pub(super) to: Vec, #[serde(default)] - #[schemars(description = "Cc recipient email addresses.")] + #[schemars( + description = "Cc recipients, in the same forms as `to`: a bare address or `Name
`, one per entry." + )] pub(super) cc: Vec, #[serde(default)] - #[schemars(description = "Bcc recipient email addresses.")] + #[schemars( + description = "Bcc recipients, in the same forms as `to`: a bare address or `Name
`, one per entry. Kept in the saved draft." + )] pub(super) bcc: Vec, #[serde(default)] - #[schemars(description = "Reply-To header addresses for responses to this draft.")] + #[schemars( + description = "Reply-To addresses for responses to this draft, in the same forms as `to`: a bare address or `Name
`, one per entry." + )] pub(super) reply_to: Vec, #[serde(default)] #[schemars(description = "Message-ID this draft replies to. Angle brackets are optional.")] @@ -353,12 +361,24 @@ pub(super) struct UpdateDraftArgs { #[serde(default)] pub(super) body: String, #[serde(default)] + #[schemars( + description = "To recipients, one per entry: a bare address or `Name
`. A name with a comma or other punctuation can be quoted (`\"Blake, Mark\" `) or written as is (`Blake, Mark `). Never put two recipients in one entry." + )] pub(super) to: Vec, #[serde(default)] + #[schemars( + description = "Cc recipients, in the same forms as `to`: a bare address or `Name
`, one per entry." + )] pub(super) cc: Vec, #[serde(default)] + #[schemars( + description = "Bcc recipients, in the same forms as `to`: a bare address or `Name
`, one per entry. Kept in the saved draft." + )] pub(super) bcc: Vec, #[serde(default)] + #[schemars( + description = "Reply-To addresses for responses to this draft, in the same forms as `to`: a bare address or `Name
`, one per entry." + )] pub(super) reply_to: Vec, #[serde(default)] pub(super) in_reply_to: Option, diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 35ea6fd..f73f5db 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -330,13 +330,23 @@ impl AgentMailServer { Ok(file_access::FileAccessPolicy::with_roots(root, additional)) } - /// Combined tool router — referenced by `#[tool_handler]`'s default - /// `Self::tool_router()` expression and by the regression tests. + /// Combined tool router. Built ONCE, into [`TOOL_ROUTER`]: `#[tool_handler]` + /// evaluates its router expression on every `call_tool`, `list_tools` and + /// `get_tool`, so calling this there rebuilt all 36 routes per request (and, + /// on each runtime thread's first request, every schema — rmcp caches them + /// per thread). The regression tests call it directly. fn tool_router() -> ToolRouter { Self::read_tools_router() + Self::write_tools_router() } } +/// The one router every request dispatches through — see +/// [`AgentMailServer::tool_router`]. `list_tools` clones its tools and patches +/// the live accounts into the copies (`Arc::make_mut` copies a shared schema on +/// write), so the shared definitions are never edited. +static TOOL_ROUTER: std::sync::LazyLock> = + std::sync::LazyLock::new(AgentMailServer::tool_router); + /// Constrain every `account` argument in a tool's input schema to the accounts /// this server actually has. /// @@ -363,7 +373,7 @@ fn patch_account_enum(schema: &mut serde_json::Map, a ); } -#[tool_handler] +#[tool_handler(router = TOOL_ROUTER)] #[prompt_handler] impl ServerHandler for AgentMailServer { /// The tool list, with the LIVE account names patched into every `account` @@ -386,7 +396,7 @@ impl ServerHandler for AgentMailServer { _request: Option, _context: rmcp::service::RequestContext, ) -> Result { - let mut tools = Self::tool_router().list_all(); + let mut tools = TOOL_ROUTER.list_all(); let accounts = self.agentmail.account_names(); if !accounts.is_empty() { for tool in &mut tools { @@ -470,7 +480,8 @@ impl ServerHandler for AgentMailServer { ## Drafts\n\ \n\ `create_draft` and `update_draft`. Both accept Reply-To, Bcc,\n\ - attachments and RFC threading headers.\n\ + attachments and RFC threading headers. Every recipient field takes\n\ + `Name
` or a bare address, one recipient per entry.\n\ \n\ A draft is From the account's primary address under its display name. Pass\n\ `from` (`Name
` or an address) to use another of the account's own\n\ @@ -1056,6 +1067,28 @@ mod tests { ); } + /// `#[tool_handler]` evaluates its router expression on EVERY call_tool, + /// list_tools and get_tool. Rebuilt per call, the router re-creates all 36 + /// routes each time, and on each runtime worker thread its first request + /// regenerates every schema (rmcp caches them per thread). Shared, a request + /// on any thread gets the same schema allocation back. + #[test] + fn the_tool_router_is_built_once_not_per_request() { + use rmcp::ServerHandler as _; + let server = server_with_accounts(&["work"]); + let here = server.get_tool("create_draft").expect("create_draft"); + let elsewhere = std::thread::scope(|scope| { + scope + .spawn(|| server.get_tool("create_draft")) + .join() + .expect("thread") + }) + .expect("create_draft"); + assert!( + std::sync::Arc::ptr_eq(&here.input_schema, &elsewhere.input_schema), + "the router was rebuilt for a request on another thread" + ); + } #[test] fn pagination_applies_defaults_and_bounds_consistently() { assert_eq!( diff --git a/src/mcp/resources.rs b/src/mcp/resources.rs index 8d6f0c4..1c3f98d 100644 --- a/src/mcp/resources.rs +++ b/src/mcp/resources.rs @@ -14,7 +14,8 @@ //! //! Attachment parts follow one naming nomenclature everywhere: the canonical //! filename is `{uid}_{index}_{sanitized-original-name}` ("unnamed" when the -//! part has no name), identical to what `download_attachments` writes to disk. +//! part has no name, shortened past 240 bytes with its extension kept), +//! identical to what `download_attachments` writes to disk. //! //! Account and mailbox are percent-encoded URI segments; a `/` inside a //! mailbox name (hierarchy delimiter) must be encoded as `%2F` so it cannot @@ -433,13 +434,10 @@ fn render_message_markdown(msg: &crate::MessageInfo) -> String { cap_chars(&out, MAX_BODY_CHARS) } -/// Canonical exposed filename for an attachment part — the same nomenclature -/// `download_attachments` uses on disk: `{uid}_{index}_{sanitized-name}`. +/// Canonical exposed filename for an attachment part — exactly the name +/// `download_attachments` writes, shortening included. fn attachment_filename(uid: u32, index: usize, name: Option<&str>) -> String { - format!( - "{uid}_{index}_{}", - crate::sanitize_filename(name.unwrap_or("unnamed")) - ) + crate::attachment_filename(uid, index, name.unwrap_or("unnamed")) } /// Drop `null` members recursively so the info document stays compact, @@ -1199,6 +1197,14 @@ mod tests { "path separators are sanitized like download_attachments does" ); assert_eq!(attachment_filename(42, 2, None), "42_2_unnamed"); + // A name too long for a filesystem is shortened — and /info advertises + // exactly the name the download writes. + let long = format!("{}.pdf", "minutes ".repeat(40)); + assert_eq!( + attachment_filename(42, 3, Some(&long)), + crate::attachment_filename(42, 3, &long) + ); + assert!(attachment_filename(42, 3, Some(&long)).len() <= 240); } fn message_with_attachments() -> crate::MessageInfo { diff --git a/src/mcp/tools_write.rs b/src/mcp/tools_write.rs index 44258b6..a929ccb 100644 --- a/src/mcp/tools_write.rs +++ b/src/mcp/tools_write.rs @@ -834,7 +834,7 @@ impl AgentMailServer { #[tool( name = "download_attachments", output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid download_attachments output schema"), - description = "Download all attachments from one message to disk. Pass mailbox, uid, and expectedUidValidity from the same find_attachments hit; the download fails before filesystem writes if the mailbox UID epoch changed. Each file is saved as {uid}_{index}_{name} with the name sanitized — the same canonical filename /info reports for that part. Omit outputDir to write into the session workspace. Returns paths, content types, and sizes.", + description = "Download all attachments from one message to disk. Pass mailbox, uid, and expectedUidValidity from the same find_attachments hit; the download fails before filesystem writes if the mailbox UID epoch changed. Each file is saved as {uid}_{index}_{name} with the name sanitized (and shortened, extension kept, when it is too long for a filesystem) — the same canonical filename /info reports for that part. Omit outputDir to write into the session workspace. Returns absolute paths, content types, and sizes. All or nothing: a name that already exists is refused before anything is written, and a failure partway removes the files already written.", annotations( title = "Download Attachments", read_only_hint = false, diff --git a/src/mcp/wire.rs b/src/mcp/wire.rs index bac06c5..9979761 100644 --- a/src/mcp/wire.rs +++ b/src/mcp/wire.rs @@ -2387,6 +2387,44 @@ mod tests { ); } + /// An embedder (the Agent Muse settings screen) reads `list_identities`' + /// structured result back into the library's `ListIdentitiesResponse`. + /// The wire output is a separate struct, so pin that the two agree. + #[test] + fn list_identities_wire_output_reads_back_into_the_library_type() { + let response = crate::ListIdentitiesResponse { + account: "work".to_string(), + display_name: Some("Mark Blake".to_string()), + sent_mailbox: Some("Sent".to_string()), + scanned_messages: 2, + identities: vec![ + crate::SenderIdentity { + address: "mark@example.com".to_string(), + configured: true, + primary: true, + display_names: vec!["Mark Blake".to_string()], + messages: 1, + last_used: chrono::DateTime::from_timestamp(1_790_000_000, 0), + }, + crate::SenderIdentity { + address: "old@example.net".to_string(), + configured: false, + primary: false, + display_names: Vec::new(), + messages: 1, + last_used: None, + }, + ], + }; + + let wire = + serde_json::to_value(ListIdentitiesOutput::from(response.clone())).expect("serializes"); + let read_back: crate::ListIdentitiesResponse = + serde_json::from_value(wire).expect("the wire output reads back"); + + assert_eq!(read_back, response); + } + #[test] fn all_tool_output_schemas_are_ref_free_root_objects() { assert_ref_free::(); diff --git a/src/mutation_journal.rs b/src/mutation_journal.rs index 604a409..9548e90 100644 --- a/src/mutation_journal.rs +++ b/src/mutation_journal.rs @@ -114,11 +114,7 @@ impl MutationJournal { } pub(crate) fn default_path() -> Option { - if let Some(path) = std::env::var_os("AGENTMAIL_CACHE_DIR") { - return Some(PathBuf::from(path).join(Self::FILE_NAME)); - } - let root = dirs::cache_dir()?; - Some(root.join("agentmail").join(Self::FILE_NAME)) + crate::default_cache_file(Self::FILE_NAME) } pub(crate) fn default_persistent() -> Self { diff --git a/src/secret.rs b/src/secret.rs index 7445347..782f180 100644 --- a/src/secret.rs +++ b/src/secret.rs @@ -21,6 +21,12 @@ static SERVICE_NAME: OnceLock = OnceLock::new(); const COMMAND_TIMEOUT: Duration = Duration::from_secs(15); const MAX_COMMAND_OUTPUT_BYTES: usize = 64 * 1024; +/// How long a keychain call may take before it is reported instead of awaited. +/// A locked keychain (after sleep, say) can hold the call until someone unlocks +/// it, and a password read runs under the account's connect lock — so without +/// a deadline every other connect for that account waited with it. +const KEYRING_TIMEOUT: Duration = Duration::from_secs(30); + /// Initialize the keyring service name. /// /// When embedded in the Agent app, this is set to the app's bundle identifier. @@ -70,6 +76,15 @@ pub enum SecretError { )] NoDefaultStore, + #[error("no password is stored in the keychain under this name")] + NoEntry, + + #[error( + "the keychain did not answer within {seconds}s; it may be locked (after sleep, for example) \ + and waiting to be unlocked. Unlock it and retry, or set AGENTMAIL_PASSWORD_." + )] + KeyringTimedOut { seconds: u64 }, + #[error("keyring backend error: {0}")] Backend(String), @@ -109,6 +124,7 @@ pub(crate) fn map_keyring_error(err: keyring_core::error::Error) -> SecretError use keyring_core::error::Error as KErr; match err { + KErr::NoEntry => SecretError::NoEntry, KErr::NoDefaultStore => SecretError::NoDefaultStore, KErr::PlatformFailure(ref inner) | KErr::NoStorageAccess(ref inner) => { classify_platform_message(&inner.to_string()) @@ -135,6 +151,21 @@ pub(crate) fn classify_platform_message(msg: &str) -> Option { } } +/// Run one blocking keychain call, reporting it at `deadline` rather than +/// waiting longer. The call itself cannot be cancelled; past the deadline it +/// finishes, unobserved, on its blocking thread. +async fn keyring_call( + deadline: Duration, + call: impl FnOnce() -> Result + Send + 'static, +) -> Result { + match tokio::time::timeout(deadline, tokio::task::spawn_blocking(call)).await { + Ok(joined) => joined.map_err(|error| SecretError::Internal(error.to_string()))?, + Err(_elapsed) => Err(SecretError::KeyringTimedOut { + seconds: deadline.as_secs(), + }), + } +} + /// A secret value that can be stored in different backends. #[derive(Clone, Deserialize)] #[serde(rename_all = "lowercase")] @@ -268,13 +299,12 @@ impl Secret { Secret::Keyring(key) => { let service = service_name().to_string(); let key = key.clone(); - tokio::task::spawn_blocking(move || { + keyring_call(KEYRING_TIMEOUT, move || { let entry = keyring_core::Entry::new(&service, &key).map_err(map_keyring_error)?; entry.get_password().map_err(map_keyring_error) }) .await - .map_err(|e| SecretError::Internal(e.to_string()))? } Secret::Command(cmd) => { command_secret_with_limits(cmd, COMMAND_TIMEOUT, MAX_COMMAND_OUTPUT_BYTES).await @@ -293,13 +323,12 @@ impl Secret { let service = service_name().to_string(); let key = key.clone(); let value = value.to_string(); - tokio::task::spawn_blocking(move || { + keyring_call(KEYRING_TIMEOUT, move || { let entry = keyring_core::Entry::new(&service, &key).map_err(map_keyring_error)?; entry.set_password(&value).map_err(map_keyring_error) }) .await - .map_err(|e| SecretError::Internal(e.to_string()))? } Secret::Command(_) => Err(SecretError::CommandNotWritable), } @@ -315,20 +344,31 @@ impl Secret { Secret::Keyring(key) => { let service = service_name().to_string(); let key = key.clone(); - tokio::task::spawn_blocking(move || { + keyring_call(KEYRING_TIMEOUT, move || { if let Ok(entry) = keyring_core::Entry::new(&service, &key) { let _ = entry.delete_credential(); } Ok(()) }) .await - .map_err(|e| SecretError::Internal(e.to_string()))? } Secret::Command(_) => Ok(()), } } } +/// Install ONE mock keyring store for the whole test process. The default +/// store is process-global, so two tests each installing their own could trade +/// stores mid-test when they share a process (`cargo test`); tests share this +/// one and keep to their own entry names instead. +#[cfg(test)] +pub(crate) fn install_mock_keyring() { + static INSTALL: std::sync::Once = std::sync::Once::new(); + INSTALL.call_once(|| { + keyring_core::set_default_store(keyring_core::mock::Store::new().expect("mock store")); + }); +} + #[cfg(test)] mod tests { use super::*; @@ -468,13 +508,33 @@ mod tests { #[tokio::test] async fn keyring_roundtrip_with_mock_store() { - keyring_core::set_default_store(keyring_core::mock::Store::new().unwrap()); + install_mock_keyring(); let mut s = Secret::new_keyring("agentmail.test.roundtrip"); s.set("hunter2").await.unwrap(); assert_eq!(s.get().await.unwrap(), "hunter2"); s.delete().await.unwrap(); - // After delete, get should fail with a backend error (NoEntry). - assert!(s.get().await.is_err()); + // After delete there is nothing stored — distinct from the keychain + // failing to answer. + assert!(matches!(s.get().await, Err(SecretError::NoEntry))); + } + + /// A keychain call that does not answer — a locked keychain can hold it + /// until someone unlocks it — is reported at the deadline, not awaited. + #[tokio::test] + async fn a_keychain_call_that_does_not_answer_is_reported_not_awaited() { + let started = std::time::Instant::now(); + + let result = keyring_call(Duration::from_millis(50), || { + std::thread::sleep(Duration::from_millis(500)); + Ok(()) + }) + .await; + + assert!( + matches!(result, Err(SecretError::KeyringTimedOut { .. })), + "{result:?}" + ); + assert!(started.elapsed() < Duration::from_millis(400)); } } diff --git a/src/types.rs b/src/types.rs index 6606175..0b03ee9 100644 --- a/src/types.rs +++ b/src/types.rs @@ -228,7 +228,7 @@ pub struct ListAccountsResponse { } /// One address an account sends as, configured or seen in its Sent mail. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] #[schemars(inline)] pub struct SenderIdentity { @@ -250,8 +250,10 @@ pub struct SenderIdentity { pub last_used: Option>, } -/// Response for list_identities. -#[derive(Debug, Clone, Serialize, JsonSchema)] +/// Response for list_identities. Also `Deserialize`, so an embedder reading +/// the `list_identities` tool's structured result gets this type back — the +/// wire output is asserted to round-trip into it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct ListIdentitiesResponse { pub account: String, diff --git a/tests/mcp_integration.rs b/tests/mcp_integration.rs index d42b74c..9a461fb 100644 --- a/tests/mcp_integration.rs +++ b/tests/mcp_integration.rs @@ -584,6 +584,28 @@ async fn draft_tools_take_a_sender_and_refuse_one_the_account_does_not_own() { } } +/// Recipients take `Name
` exactly as `from` does, and every +/// recipient field of both draft tools says so — an agent that only sees +/// "email addresses" strips the names the user typed. +#[tokio::test] +async fn every_recipient_field_documents_the_named_form() { + let mut client = McpClient::start().await; + let resp = client.request("tools/list", json!({})).await; + let tools = resp["result"]["tools"].as_array().expect("tools array"); + for name in ["create_draft", "update_draft"] { + let properties = &find_tool(tools, name)["inputSchema"]["properties"]; + for field in ["to", "cc", "bcc", "replyTo"] { + let description = properties[field]["description"] + .as_str() + .unwrap_or_default(); + assert!( + description.contains("Name
"), + "`{name}.{field}` must document the named form: {description:?}" + ); + } + } +} + /// Adding `from` did not loosen the schema: a near-miss name is still refused /// with the field named, rather than silently ignored. #[tokio::test] @@ -1235,6 +1257,37 @@ async fn tools_list_carries_the_live_accounts_in_the_account_enum() { ); } +/// Every server in a process dispatches through ONE shared tool router, and +/// `tools/list` patches each server's accounts into copies of its schemas. A +/// patch written into the shared definitions would leak one server's accounts +/// into the next server's list. +#[tokio::test] +async fn servers_sharing_the_tool_router_each_advertise_only_their_own_accounts() { + let server = |name: &str| { + Config::from_accounts(vec![( + name.to_string(), + AccountConfig::new("imap.invalid", format!("{name}@example.invalid")), + )]) + }; + let mut clients = [ + (McpClient::start_with(server("work")).await, "work"), + (McpClient::start_with(server("home")).await, "home"), + ]; + + // work, home, then work again: the second list of a server must not carry + // what the other server patched in between. + for index in [0, 1, 0] { + let (client, expected) = &mut clients[index]; + let resp = client.request("tools/list", json!({})).await; + let tools = resp["result"]["tools"].as_array().expect("tools array"); + assert_eq!( + find_tool(tools, "get_messages")["inputSchema"]["properties"]["account"]["enum"], + json!([*expected]), + "each server lists only its own accounts" + ); + } +} + /// EVERY advertised template completes, including the mailbox index — which is /// listed FIRST and is the one an agent reaches for before it knows any UID. /// It was omitted from the completion whitelist, so the most reachable template From 82fd8e96ff163562eef4ed3d520a0a8d4354a321 Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:58:33 -0700 Subject: [PATCH 04/25] numbers 1 and two fixed --- CHANGELOG.md | 35 + DECISIONS.md | 108 +++ MCP.md | 51 +- README.md | 10 +- docs/plans/10-07-2027_Dangaling_Bugs.md | 18 +- src/connection.rs | 17 + src/imap_client.rs | 877 +++++++++++++++++++++++- src/lib.rs | 818 +++++++++++++++++++--- src/main.rs | 47 +- src/mcp/args.rs | 5 + src/mcp/mod.rs | 4 +- src/mcp/tools_read.rs | 4 +- src/mcp/tools_write.rs | 18 +- src/mcp/wire.rs | 42 ++ src/mutation_journal.rs | 143 +++- src/types.rs | 25 +- tests/mcp_integration.rs | 64 ++ 17 files changed, 2117 insertions(+), 169 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b64e157..45c0f43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Every recipient field documents `Name
`** — `to`, `cc`, `bcc` and `replyTo` on both draft tools, and the handshake instructions — so agents keep the names users write instead of stripping them to bare addresses. +- **A stuck move can be dismissed** — `reconcile_moves` with `dismiss: true` + and an `operationId` (CLI `reconcile-moves --dismiss`, library + `Agentmail::dismiss_move`) closes a move reconciliation can't finish, once + both mailboxes have been checked. It moves, copies and deletes nothing; it + releases the source message and frees both mailboxes for rename and delete. + Results gain `dismissed` and `errors`. - **Evidence-grade RFC822 archive tools** — `download_message_source` writes one exact `BODY.PEEK[]` result directly to a create-new private file, returning its SHA-256, parsed message metadata, and a contemporaneous DNS-backed local @@ -63,6 +69,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 rebuilds on its own. - **`SecretError` gains `NoEntry` and `KeyringTimedOut`** (breaking for exhaustive matches): a missing keychain entry was `Backend(..)`. +- **`list_flags` and `find_attachments` report `skipped`** (with + `skippedTotal`/`skippedTruncated`, as the sweeps do): the mailboxes an + account-wide scan did not cover. A mailbox it could not open used to vanish + from the result without a trace. +- **`reconcile_moves` examines `needsAttention` moves again** and deletes a + source only right after seeing its copy (the `COPYUID`) in the destination; a + copy deleted in the meantime keeps the source and asks for review. An attempt + that learns nothing counts as `pending` and is listed in `errors`, so `failed` + now means only a COPY the server rejected. - **The tool router is built once per process**, not on every `call_tool`, `list_tools` and `get_tool`. - **Dependencies** — async-imap 0.11.3 → 0.12.0 (LOGIN no longer fails on a @@ -80,6 +95,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 end-of-stream, so after sleep or a server `BYE` the pool handed out dead sessions and drafts reported a false "APPEND outcome is ambiguous". `NOOP` now passes only on its own tagged `OK`. +- **One dropped connection could strand a COPY-fallback move for good.** Any + failure opening the source mailbox during reconciliation — a lost connection + included — parked the move as `needsAttention`, which reconciliation then + returned unexamined, and a pending move blocks rename and delete of both its + mailboxes. Now only a live server's answer (a new `UIDVALIDITY`, + `NO [NONEXISTENT]`) parks a move; anything else leaves it as it was. +- **A connection lost right after SELECT could mark a move complete** while + the source survived, releasing its claim: async-imap reads end-of-stream as + an empty SEARCH. The check now needs the search's own tagged `OK`, and on a + Yahoo/AOL Limited Mode session "not found" proves nothing. +- **A dropped connection made account-wide scans and sweeps report mailboxes + they never read.** On a closed stream SELECT, EXAMINE, SEARCH and FETCH come + back empty instead of failing, so every remaining mailbox "drained" or + counted as empty, the dead session went back to the pool, and a hung one + waited out the timeout (120 s in the app) once per remaining mailbox. A + mailbox now counts only once the connection answers a checked `NOOP` after + it; a lost connection ends the scan or sweep at once and lists that mailbox + and every later one in `skipped`. A single-mailbox `list_flags` or + `find_attachments`, and `preview_thread_record`, fail instead of returning a + partial answer. - **Pool and catalog timers stopped while the Mac slept.** Idle eviction, the `[LIMIT]` login cooldown and the mailbox-catalog TTL now run on the wall clock; a clock set back counts as expired. diff --git a/DECISIONS.md b/DECISIONS.md index d3cd72f..2e335a7 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -116,6 +116,114 @@ must too. A backwards wall clock cannot say how much time passed; treating it as expired costs at most one reconnect or one LIST, while trusting it could stretch a cooldown by however far the clock moved. +## 0.7.0 — A Scanned Mailbox Counts Once The Connection Answers After It + +### Decision + +An account-wide scan or sweep counts a mailbox only after +`imap_client::confirm_alive` — a `NOOP` that only its own tagged `OK` passes — +succeeds following the mailbox's last command. A lost connection ends the scan +at once: that mailbox and every later one go to `skipped`, and the session is +dropped, not pooled. A mailbox that failed on a connection the probe then +proves alive is skipped on its own and the scan goes on. `list_flags` and +`find_attachments` gain `skipped` like the sweeps; a single-mailbox scan, and +`preview_thread_record`, fail instead of answering partially. + +### Rationale + +async-imap ends SELECT/EXAMINE (`parse_mailbox`), SEARCH (`parse_ids`) and FETCH +quietly at end-of-stream, so a dropped connection is not an error but an empty +mailbox, an empty search, a short fetch. The loops classified failures per +mailbox and moved on, so after a drop every remaining mailbox "drained" with +nothing found — or failed on its own and was skipped — and the sweep returned +`Ok` with `session_usable` still true, pooling the dead session. On a hung +connection each remaining mailbox instead waited out the timeout, 120 s in the +app. Checking errors with `is_connection_error()` alone could not fix it, +because the commonest failure was not an error at all. + +A connection that ended can't answer a NOOP afterwards, so one probe per mailbox +proves every answer before it came from a live connection — a property no +per-command check gives without rewriting every command by hand, which is what +the async-imap fork (finding 15) would do upstream. It costs one round trip per +mailbox. It does not catch a tagged `NO` that async-imap swallows on a live +connection; that is still finding 15. + +### Consequence + +`ListFlagsResponse` and `FindAttachmentsResponse` gain `skipped` (outputs: +`skipped`, `skippedTotal`, `skippedTruncated`). In a sweep, a mailbox whose +connection became unusable mid-mailbox is now listed in `skipped` as well as +in `mailboxes`, since later passes never ran. Pinned by +`a_sweep_stops_at_a_lost_connection_and_reports_what_it_did_not_cover`, +`a_flag_scan_reports_the_mailboxes_a_lost_connection_left_unscanned`, +`a_flag_scan_skips_a_refused_mailbox_and_scans_the_rest`, +`a_single_mailbox_flag_scan_fails_on_a_lost_connection`, +`an_attachment_scan_reports_the_mailboxes_a_lost_connection_left_unscanned`, +and `thread_discovery_fails_when_the_connection_is_lost`. + +## 0.7.0 — Move Reconciliation Acts Only On A Live Server's Answer + +### Decision + +`reconcile_journaled_move` changes the journal only on an answer a live server +gave. Opening a mailbox: a new UIDVALIDITY, `NO [NONEXISTENT]`, or an open +without UIDVALIDITY on a connection a NOOP then proves alive parks the move as +`needsAttention`; anything else — a dropped connection, a timeout, a refusal +such as `[UNAVAILABLE]` — returns the error and leaves the operation exactly as +it was (`reconcile_moves` counts it `pending` and lists it in `errors`). +"Not there" comes from `uid_search_checked`, which needs the search's own +tagged `OK`, and counts only on a session that sees the whole mailbox. A source +is deleted only right after its copy (the `COPYUID`) has been seen in the +destination. `needsAttention` moves are examined again on every reconcile, +resuming from what the journal knows for certain: `Copied` with a COPYUID, +otherwise `CopyInFlight`, whose UIDNEXT rule never copies twice. `dismiss` +(`reconcile_moves` `dismiss: true` + `operationId`, `Agentmail::dismiss_move`) +closes one move for good and touches neither mailbox. + +### Rationale + +async-imap returns `Ok` from SELECT at end-of-stream and drains SEARCH with +`take_while`, so one dropped connection read as two different facts. During +SELECT it looked like a mailbox without UIDVALIDITY — "source mailbox epoch +changed" — and parked the move as `needsAttention`, which reconcile then +returned unexamined. A pending move holds its source and destination against +rename and delete, and the only remedy offered was to reconcile it: one +connection drop could lock two mailboxes for good. After SELECT it looked like +an empty search — "source gone" — and marked the move complete, releasing the +claim while the source survived beside its copy. + +Re-examining is what unsticks the moves the old classification parked, and the +copy check is what makes re-examining safe. A move can wait days, and the very +symptom of a pending move — the message still showing in its old mailbox — is +what invites a person to delete the "duplicate" copy. Reconcile used to delete +the source anyway; now it keeps it and asks. (The tool description already +promised it removes a source "only after it can prove the destination copy".) +Without a COPYUID (`UIDNOTSTICKY`) there is no copy to look for, and the COPY's +`OK` stays the only evidence. + +On a Limited Mode session (Yahoo/AOL outside UID Mode) UIDs below the window +search as absent, so absence proves nothing there. Yahoo and AOL advertise +MOVE, so the journal never runs on them today; the guard is for a server that +pairs UIDONLY with no MOVE. + +`dismiss` names one operation because closing every pending move at once is +the bulk "forget what we were doing" the journal exists to prevent. + +### Consequence + +Journal state `dismissed`, pruned with `complete` and `copy_failed`; no schema +change, since older builds read inactive rows only by id. `ReconcileMovesResponse` +gains `dismissed` and `errors`, and `failed` now means only a rejected COPY (an +attempt error used to count there). Pinned by +`a_connection_lost_while_selecting_leaves_the_move_as_it_was`, +`a_connection_lost_after_selecting_does_not_complete_the_move`, +`a_copy_gone_from_the_destination_keeps_the_source`, +`a_move_needing_attention_is_re_examined_and_finished`, +`re_examining_without_a_copyuid_never_copies_twice`, +`a_dismissed_move_no_longer_blocks_its_mailboxes`, and +`async_imaps_own_search_reports_a_closed_stream_as_no_match` (the upstream +behavior, so the day it changes is visible). + --- ## 0.5.0 — Two Tool Pairs Become One Tool Each diff --git a/MCP.md b/MCP.md index 9c1cd24..9343f67 100644 --- a/MCP.md +++ b/MCP.md @@ -167,10 +167,13 @@ needed. A tool result never carries a URI in its JSON — see "flags": [{ "flag": "\\Seen", "count": 5000 }], "colors?": [{ "color": "red", "count": 8 }], "perMailbox?": [{ "mailbox", "totalFlags", "flags": [...] }], - "perMailboxTotal", "perMailboxTruncated" } + "perMailboxTotal", "perMailboxTruncated", + "skipped": ["mailbox"], "skippedTotal", "skippedTruncated" } ``` `colors` is present when Apple $MailFlagBit flags exist. Account-wide mailbox breakdowns are capped at 50 rows and include total/truncation metadata. +`skipped` lists the mailboxes an account-wide scan did not cover (see +**Coverage** below); their messages are in none of the counts. **find_attachments** ```json @@ -178,12 +181,24 @@ breakdowns are capped at 50 rows and include total/truncation metadata. "nextOffset?", "messages": [{ "mailbox", "uidValidity", "uid", "date?" }], "perMailbox?": [{ "mailbox", "count" }], - "perMailboxTotal", "perMailboxTruncated" } + "perMailboxTotal", "perMailboxTruncated", + "skipped": ["mailbox"], "skippedTotal", "skippedTruncated" } ``` Results are paginated newest-first (default 25, maximum 100). Every hit carries its owning mailbox and UID epoch, so account-wide UIDs are never ambiguous. Mailbox breakdowns are capped at 50 rows and include total/truncation metadata. +`skipped` lists the mailboxes the scan did not cover; they are not in `total`. + +**Coverage.** A mailbox counts as scanned only once the connection answers a +`NOOP` after its last command: on a closed stream SELECT, EXAMINE, SEARCH and +FETCH come back empty or short instead of failing, so without the check a +dropped connection would report every remaining mailbox as covered and empty. +A mailbox that fails on its own is skipped and the scan goes on; a lost +connection ends an account-wide scan or sweep at once, and that mailbox and +every later one are listed in `skipped` rather than each waiting out a +timeout. A single-mailbox scan fails instead, and `preview_thread_record` +fails rather than return a graph missing what it could no longer search. **top_senders** ```json @@ -426,7 +441,7 @@ truncation fields preserve audit completeness. "skipped": ["mailbox"], "skippedTotal", "skippedTruncated", "permanent": bool } ``` -`mailboxes` is present when scanning all mailboxes. `skipped` lists planned mailboxes that could not be selected or searched; policy-excluded special-use views are not reported as errors. +`mailboxes` is present when scanning all mailboxes. `skipped` lists planned mailboxes that could not be selected or searched, were not fully drained, or came after a lost connection (see **Coverage** under find_attachments); policy-excluded special-use views are not reported as errors. **delete_by_domain** ```json @@ -491,9 +506,18 @@ truncation fields preserve audit completeness. **reconcile_moves** ```json { "account", "examined", "completed", "pending", "needsAttention", - "failed", "operations": [PendingMove] } + "failed", "dismissed", + "errors": [{ "operationId", "error" }], + "operations": [PendingMove] } ``` +`failed` counts moves whose COPY the server rejected (the source was never +touched). An attempt that learned nothing — a dropped connection, a timeout — +leaves its operation as it was, counts in `pending`, and is listed in +`errors`. `operations` is what is still pending after the call. With +`dismiss: true` and an `operationId`, the call closes that one move instead +(`dismissed: 1`). + ```json PendingMove = { "operationId", "sourceMailbox", "sourceUidValidity", "sourceUid", @@ -524,9 +548,20 @@ session is discarded and the response reports `reconciliationPending` with an `operationId` rather than claiming success or issuing an unsafe second COPY. `reconcile_moves` retries COPY only when unchanged destination `UIDNEXT` proves that the earlier attempt created nothing; otherwise it continues source cleanup -or reports `needsAttention` for explicit review. The journal uses -`synchronous=FULL` and remains enabled even when the disposable ranking cache -is disabled. +or reports `needsAttention` for explicit review. It acts only on a live +server's answer: a new `UIDVALIDITY`, `NO [NONEXISTENT]`, or a mailbox opened +without `UIDVALIDITY` on a connection a `NOOP` then proves alive parks the move +as `needsAttention`; a dropped connection, a timeout or an unrelated refusal +changes nothing. "Not found" comes from a `UID SEARCH` that must see its own +tagged `OK`, and counts only on a session that sees the whole mailbox (not +Yahoo/AOL Limited Mode). A source is deleted only right after its copy (the +`COPYUID`) has been seen in the destination; a copy deleted in the meantime +keeps the source and asks for review. `needsAttention` moves are examined again +on every call, and one that still can't finish is closed with `dismiss: true` +and its `operationId` once both mailboxes have been checked — that moves, +copies and deletes nothing, and frees both mailboxes for rename and delete. The +journal uses `synchronous=FULL` and remains enabled even when the disposable +ranking cache is disabled. **create_mailbox** ```json @@ -551,7 +586,7 @@ preflight data: The confirmed call must echo `expectedMessageCount`. A changed count fails closed. INBOX and mailboxes referenced by a pending MOVE journal are never -eligible. Special-use and descendant-bearing mailboxes need separate +eligible (reconcile the move, or dismiss it, to release them). Special-use and descendant-bearing mailboxes need separate acknowledgements; deleting a non-empty mailbox needs `confirmNonEmpty` as well. The rename destination must not exist. A missing delete target is an idempotent success. After a transport error, AgentMail re-lists the mailbox catalog and diff --git a/README.md b/README.md index 5d50dc7..dabd4c6 100644 --- a/README.md +++ b/README.md @@ -395,6 +395,7 @@ agentmail create-draft --account gmail --subject "Hello" --body "Hi there" --to agentmail create-draft --account gmail --from "Mark Blake " --subject "Hello" --body "Hi" --to user@example.com agentmail list-pending-moves --account gmail agentmail reconcile-moves --account gmail --operation-id +agentmail reconcile-moves --account gmail --operation-id --dismiss ``` Full subcommand list: `agentmail --help` @@ -687,13 +688,20 @@ with: agentmail list-pending-moves --account gmail agentmail reconcile-moves --account gmail --operation-id # Omit --operation-id to reconcile every pending operation for the account. +agentmail reconcile-moves --account gmail --operation-id --dismiss +# Closes one move reconciliation can't finish. Check both mailboxes first: +# dismissing moves, copies and deletes nothing. ``` The recovery database lives beside the ranking cache (both directly in `AGENTMAIL_CACHE_DIR` when it is set), uses `synchronous=FULL`, and has owner-only permissions on Unix. It remains enabled when header-cache persistence is disabled because mutation intent is not disposable. Reconciliation repeats COPY only when -unchanged destination `UIDNEXT` proves the ambiguous attempt created nothing. +unchanged destination `UIDNEXT` proves the ambiguous attempt created nothing, +deletes a source only right after seeing its copy in the destination, and +changes nothing on an attempt that loses its connection. `needsAttention` +moves are examined again each time. A pending move holds its source and +destination against rename and delete until it finishes or is dismissed. ## MCP Prompts diff --git a/docs/plans/10-07-2027_Dangaling_Bugs.md b/docs/plans/10-07-2027_Dangaling_Bugs.md index 1d61605..c748856 100644 --- a/docs/plans/10-07-2027_Dangaling_Bugs.md +++ b/docs/plans/10-07-2027_Dangaling_Bugs.md @@ -6,8 +6,8 @@ Here are the 16 Part 5 findings, each re-checked against today's code, with an e | # | Sev | Finding | Where | Effort | What drives it | Status | |---|---|---|---|---|---|---| -| 1 | Med | Account-wide scans keep using a session after a connection error, waiting the app's 120 s timeout per remaining folder, and still return `Ok` | `lib.rs:2489`, `:2504`, `:2757`, `:3026`, `:4646` | M | Four sites. Two scan outputs need a new skipped-mailboxes field to report the gap. | -| 2 | **High** | Any SELECT failure during move reconciliation becomes a permanent `NeedsAttention`, and nothing can clear it. A BYE right after SELECT can mark a move Complete while the source copy survives. | `imap_client.rs:2892`, `:2918` | L | Error classification (S) and a search that must see its own tagged OK (S–M), plus a new tool or argument to clear stuck moves (M). | +| 1 | Med | Account-wide scans keep using a session after a connection error, waiting the app's 120 s timeout per remaining folder, and still return `Ok` | `lib.rs:2489`, `:2504`, `:2757`, `:3026`, `:4646` | M | Four sites. Two scan outputs need a new skipped-mailboxes field to report the gap. | Done. Wider than reported: on a closed stream SELECT/SEARCH/FETCH return empty `Ok`s, so the remaining folders mostly "drained" as empty rather than timing out, and the dead session was pooled. A mailbox now counts only after a checked NOOP passes; a lost connection stops the scan and lists the rest in `skipped` (new on `list_flags`/`find_attachments`); thread preview fails instead. | +| 2 | **High** | Any SELECT failure during move reconciliation becomes a permanent `NeedsAttention`, and nothing can clear it. A BYE right after SELECT can mark a move Complete while the source copy survives. | `imap_client.rs:2892`, `:2918` | L | Error classification (S) and a search that must see its own tagged OK (S–M), plus a new tool or argument to clear stuck moves (M). | Done. Only a live server's answer moves the journal; `needsAttention` moves are re-examined; `reconcile_moves` gains `dismiss` (+ CLI `--dismiss`), `dismissed` and `errors`. Added: a source is deleted only right after its copy is seen in the destination, so re-examining can't lose a message whose copy was removed meanwhile. | | 3 | Med | Login retries `AUTHENTICATIONFAILED` 3 times, even on Yahoo/AOL | `imap_client.rs:444`, `:612` | XS | The code change is tiny. Whether Yahoo sends transient auth failures needs a decision or a real-account check. | | 4 | Med | Cancelling a task aborts it mid-mutation and loses the partial counts | `mcp/tasks.rs:353` | L | Cooperative stop with a grace period, sweeps returning partial totals instead of an error, and a `cancelled` field in outputs. | | 5 | Low-Med | An unsolicited `FETCH (FLAGS…)` becomes a UID-0 row, which fails the page or adds a bogus one | `imap_client.rs:2021`, `:2219` | S | Keep only requested UIDs, and audit the 4 other fetch loops. | Done | @@ -21,7 +21,19 @@ Here are the 16 Part 5 findings, each re-checked against today's code, with an e | 13 | Low | A failed `download_attachments` doesn't report files already written | `lib.rs:6471` | S | Pre-check every target name first, and clean up or report on a mid-write failure. | Done | | 14 | Low | `AGENTMAIL_CACHE_DIR` means `/agentmail/` for the header cache but `/` for the move journal | `header_cache.rs:1394` vs `mutation_journal.rs:117` | XS | Move the header cache (a one-time rebuild), never the journal: pending moves live there. | Done A layout test covering both files, with and without the variable. | | 15 | Upstream | async-imap 0.12 still swallows a tagged NO/BAD after SEARCH/FETCH | async-imap `parse.rs` | M | Carry a fork like the imap-proto one, plus an upstream PR. Filing the issue alone is XS. | -| 16 | Docs | CHANGELOG has no 0.5/0.6 sections; ARCHITECTURE.md points at a nonexistent path | `CHANGELOG.md`, parent `ARCHITECTURE.md:266` | XS + S | There are no v0.5.0/v0.6.0 git tags, so the release boundaries have to be reconstructed from history. | Done +| 16 | Docs | CHANGELOG has no 0.5/0.6 sections; ARCHITECTURE.md points at a nonexistent path | `CHANGELOG.md`, parent `ARCHITECTURE.md:266` | XS + S | There are no v0.5.0/v0.6.0 git tags, so the release boundaries have to be reconstructed from history. | Half done: the ARCHITECTURE.md path is fixed; the CHANGELOG 0.5/0.6 sections are still missing. | + +**Fixed alongside (not in the 16):** +- Attachment names past a filesystem's 255-byte limit could not be downloaded; the canonical name is now capped at 240 bytes for both the download and `/info`. +- `to`/`cc`/`bcc`/`replyTo` document `Name
`; Mail Accounts settings offer the account's Sent From addresses as aliases. + +**New findings while fixing #1 and #2 (reported, not fixed):** + +| # | Sev | Finding | Where | Effort | +|---|---|---|---|---| +| 17 | Med | `sync` (the post-mutation NOOP) is async-imap's unchecked one, and five call sites `?` it after the mutation landed: a timeout there reports a completed CREATE, flag change, delete, move or superseded-draft cleanup as failed. On a closed stream it passes, so the dead session is pooled (the next acquire's ping catches it). | `imap_client.rs` `sync`; `lib.rs` `create_mailbox`, `update_flags`, `delete_messages`, `move_message`, `discard_superseded_draft` | S | +| 18 | Med | `list_mailbox_layout` reads LIST to end-of-stream, so a connection lost mid-LIST caches a partial mailbox catalog for its TTL, and account-wide scans silently skip the missing mailboxes. | `imap_client.rs` `list_mailbox_layout`, `mailbox_catalog.rs` | S | +| 19 | Low | Single-mailbox reads (`search_messages`, `get_messages`, `list_identities`) still take an empty or short SEARCH/FETCH from a closed stream as the answer. Same root as #15; `confirm_alive` after the read would close it per call. | `lib.rs` | S | **Changed on re-check:** - **#2 is worse than reported, so I've raised it to High.** A stuck move also blocks renaming or deleting both its source and destination mailboxes. The only remedy offered is "reconcile that operation", and reconcile returns `NeedsAttention` immediately. So one transient connection drop can lock two mailboxes for good. diff --git a/src/connection.rs b/src/connection.rs index 5d7603e..e1fe3a4 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -623,6 +623,23 @@ impl ConnectionPool { /// right store. Stale/dead candidates are dropped (connection closes) and /// the next store is tried. The pool lock is held only for each pop — never /// across the liveness ping. + /// Park `session` as an idle Limited-Mode connection for `account_name`, so + /// a test can drive the library's own `acquire` against a scripted server. + /// `acquire` pings it before handing it out, so the script must answer + /// `NOOP`. + #[cfg(test)] + pub(crate) async fn hold_idle_for_test(&self, account_name: &str, session: ImapSession) { + self.pools + .lock() + .await + .entry(account_name.to_string()) + .or_default() + .push(IdleSession { + session, + idle_since: SystemTime::now(), + }); + } + async fn pop_idle_any(&self, account_name: &str) -> Option<(ImapSession, bool)> { for (store, uid_mode) in [(&self.uid_pools, true), (&self.pools, false)] { let maybe_idle = { diff --git a/src/imap_client.rs b/src/imap_client.rs index c962328..98dd028 100644 --- a/src/imap_client.rs +++ b/src/imap_client.rs @@ -983,6 +983,81 @@ where } } +/// Prove the connection still answers before trusting what it just said. +/// +/// async-imap ends SELECT, EXAMINE, SEARCH and FETCH quietly when the stream +/// closes, so a dead connection reads as an empty mailbox, an empty search or +/// a short fetch. A connection that ended can't answer a NOOP afterwards, so a +/// passing [`noop_checked`] proves every earlier answer came from a live one. +/// Account-wide scans call this after each mailbox, and count the mailbox only +/// once it passes. +pub(crate) async fn confirm_alive(session: &mut Session) -> Result<()> +where + T: AsyncRead + AsyncWrite + Unpin + fmt::Debug + Send, +{ + imap_timeout(noop_checked(session)).await +} + +/// `UID SEARCH` that only its own tagged `OK` can answer. +/// +/// async-imap's `uid_search` drains with `take_while(filter)` like its NOOP +/// (docs/patches/async-imap-surface-tagged-status.md), so a connection that +/// ends and a tagged `NO` both come back as an empty `Ok` — "no such message". +/// Wherever "not found" decides something irreversible (move reconciliation +/// marks a move complete on it), the answer has to come from a live server: +/// end-of-stream and an untagged `BYE` are a lost connection, `NO`/`BAD` are +/// errors, and unsolicited updates in between are skipped. +async fn uid_search_checked(session: &mut Session, query: &str) -> Result> +where + T: AsyncRead + AsyncWrite + Unpin + fmt::Debug + Send, +{ + use async_imap::imap_proto::{MailboxDatum, Response, Status}; + + imap_timeout(async { + let tag = session.run_command(format!("UID SEARCH {query}")).await?; + let mut uids = Vec::new(); + loop { + let Some(response) = session.read_response().await? else { + return Err(AgentmailError::Imap( + async_imap::error::Error::ConnectionLost, + )); + }; + match response.parsed() { + Response::MailboxData(MailboxDatum::Search(found)) => uids.extend_from_slice(found), + Response::Data { + status: Status::Bye, + .. + } => { + return Err(AgentmailError::Imap( + async_imap::error::Error::ConnectionLost, + )); + } + Response::Done { + tag: done, + status, + outcome, + } if done == &tag => { + let detail = format!("UID SEARCH {query}: {:?}", outcome.information); + return match status { + Status::Ok => Ok(uids), + Status::No => { + Err(AgentmailError::Imap(async_imap::error::Error::No(detail))) + } + Status::Bad => { + Err(AgentmailError::Imap(async_imap::error::Error::Bad(detail))) + } + other => Err(AgentmailError::Other(format!( + "UID SEARCH completed with unexpected status {other:?}: {detail}" + ))), + }; + } + _ => {} + } + } + }) + .await +} + /// Query server capabilities via IMAP CAPABILITY command. pub async fn list_capabilities(session: &mut ImapSession) -> Result> { let mut result = capability_strings(session).await?; @@ -2823,6 +2898,14 @@ where session_usable: true, }); } + // `prepare` resumes only an active operation, and a dismissed one is + // not; answering anyway beats a panic if that ever changes. + MoveJournalState::Dismissed => { + return Err(AgentmailError::Other(format!( + "move operation '{}' was dismissed", + operation.operation_id + ))); + } } let operation = context @@ -2896,12 +2979,184 @@ where } } +/// What opening a mailbox told move reconciliation. +enum Opened { + /// Open, under the epoch the journal recorded. + Open, + /// A live server's answer that the journal's UIDs no longer name anything + /// in this mailbox. The reason becomes the operation's detail. + Gone(String), +} + +/// Open `mailbox` for reconciliation and decide what a failure means. +/// +/// Only a live server's answer may move the journal: a new UIDVALIDITY, a +/// `NO [NONEXISTENT]` (RFC 5530), or an open that carried no UIDVALIDITY on a +/// connection a NOOP then proves alive. async-imap returns `Ok` from SELECT +/// and EXAMINE at end-of-stream, so a dead connection reaches the epoch check +/// as "no UIDVALIDITY"; the NOOP tells the two apart. Anything else — a lost +/// connection, a timeout, a refusal that says nothing about the mailbox — is +/// returned as the error it is, and the operation keeps its state. +async fn open_for_reconcile( + session: &mut Session, + mailbox: &str, + expected_uid_validity: u32, + read_only: bool, +) -> Result +where + T: AsyncRead + AsyncWrite + Unpin + fmt::Debug + Send, +{ + let opened = if read_only { + examine_with_expected_uid_validity(session, mailbox, expected_uid_validity).await + } else { + select_with_expected_uid_validity(session, mailbox, expected_uid_validity).await + }; + match opened { + Ok(_) => Ok(Opened::Open), + Err(AgentmailError::UidValidityChanged { actual, .. }) => Ok(Opened::Gone(format!( + "'{mailbox}' has a new UIDVALIDITY ({}, was {expected_uid_validity}), so the journal's UIDs no longer name its messages", + actual.map_or_else(|| "none".to_string(), |actual| actual.to_string()) + ))), + Err(AgentmailError::UidValidityUnavailable { .. }) => { + imap_timeout(noop_checked(session)).await?; + Ok(Opened::Gone(format!( + "'{mailbox}' opened without a UIDVALIDITY, so the journal's UIDs cannot be checked against it" + ))) + } + Err(AgentmailError::Imap(async_imap::error::Error::No(text))) + if text.to_ascii_uppercase().contains("[NONEXISTENT]") => + { + Ok(Opened::Gone(format!("'{mailbox}' no longer exists"))) + } + Err(error) => Err(error), + } +} + +/// Whether `uid` is in the open mailbox, as only a live server can say. +/// +/// On a Limited Mode session (Yahoo/AOL outside UID Mode) a UID below the +/// visible window searches as absent, so there "not found" proves nothing: it +/// is returned as an error, leaving the operation for a session that sees the +/// whole mailbox. +async fn uid_present( + session: &mut Session, + mailbox: &str, + uid: u32, + whole_mailbox_visible: bool, +) -> Result +where + T: AsyncRead + AsyncWrite + Unpin + fmt::Debug + Send, +{ + if uid_search_checked(session, &format!("UID {uid}")) + .await? + .contains(&uid) + { + return Ok(true); + } + if !whole_mailbox_visible { + return Err(AgentmailError::Other(format!( + "cannot confirm UID {uid} is gone from '{mailbox}': a Limited Mode session does not see messages below its window" + ))); + } + Ok(false) +} + +/// Why the copy a move made can't be vouched for, or `None` once it is seen. +/// +/// A pending move can wait days for reconciliation, and meanwhile any client +/// can delete or move the copy — including a person who saw the message in +/// both mailboxes and removed the "duplicate". Deleting the source then would +/// lose the message, so reconciliation deletes a source only right after it +/// has seen the copy. Without a COPYUID (a `UIDNOTSTICKY` destination) there +/// is no copy to look for, and the COPY's own `OK` is all the evidence there +/// is. +async fn missing_copy( + session: &mut Session, + operation: &crate::mutation_journal::MoveOperation, + whole_mailbox_visible: bool, +) -> Result> +where + T: AsyncRead + AsyncWrite + Unpin + fmt::Debug + Send, +{ + let Some(copied_uid) = operation.copied_uid else { + return Ok(None); + }; + // A parked move may have been through cleanup too (a refused EXPUNGE + // after its STORE landed); the journal no longer says which. + let kept = if matches!( + operation.state, + crate::mutation_journal::MoveJournalState::DeleteInFlight + | crate::mutation_journal::MoveJournalState::NeedsAttention + ) { + "the source was kept, though an interrupted cleanup may have flagged it \\Deleted" + } else { + "the source was kept" + }; + let epoch = operation + .copied_uid_validity + .unwrap_or(operation.destination_uid_validity); + if let Opened::Gone(reason) = + open_for_reconcile(session, &operation.destination, epoch, true).await? + { + return Ok(Some(format!("destination {reason}; {kept}"))); + } + if uid_present( + session, + &operation.destination, + copied_uid, + whole_mailbox_visible, + ) + .await? + { + return Ok(None); + } + Ok(Some(format!( + "the copy (UID {copied_uid}) is no longer in '{}'; {kept}", + operation.destination + ))) +} + +/// Park `operation` for a person, from the state it was examined in. +async fn mark_needs_attention( + context: JournalMoveContext<'_>, + operation: crate::mutation_journal::MoveOperation, + reason: &str, +) -> Result { + context + .journal + .transition( + &operation.operation_id, + &[operation.state], + crate::mutation_journal::MoveJournalState::NeedsAttention, + Some(reason), + ) + .await?; + Ok(MoveItemOutcome { + status: crate::types::MoveStatus::NeedsAttention, + operation_id: Some(operation.operation_id), + session_usable: true, + }) +} + /// Resume one durable COPY-based MOVE without ever issuing a second COPY -/// unless destination UIDNEXT proves that the first command changed nothing. +/// unless destination UIDNEXT proves that the first command changed nothing, +/// and without deleting a source whose copy it has not just seen. +/// +/// Only a live server's evidence moves the journal. An attempt that learns +/// nothing — the connection dropped, a command timed out, the server refused +/// for a reason that says nothing about the move — returns `Err` and leaves +/// the operation exactly as it was. A move that `NeedsAttention` is examined +/// again from the last thing the journal knows for certain, so one a +/// transient failure parked there finishes once the evidence allows; one that +/// still can't finish stays parked until it is dismissed. +/// +/// `whole_mailbox_visible` is false on a Limited Mode session (Yahoo/AOL +/// outside UID Mode), where "not found" proves nothing. pub(crate) async fn reconcile_journaled_move( session: &mut Session, context: JournalMoveContext<'_>, operation: crate::mutation_journal::MoveOperation, + whole_mailbox_visible: bool, ) -> Result where T: AsyncRead + AsyncWrite + Unpin + fmt::Debug + Send, @@ -2909,7 +3164,10 @@ where use crate::mutation_journal::MoveJournalState; use crate::types::MoveStatus; - match operation.state { + // Where the move resumes. A COPYUID proves the copy was made; without + // one, whether it was is exactly what CopyInFlight asks of the + // destination's UIDNEXT. + let resume = match operation.state { MoveJournalState::Complete => { return Ok(MoveItemOutcome { status: MoveStatus::Moved, @@ -2924,46 +3182,67 @@ where session_usable: true, }); } - MoveJournalState::NeedsAttention => { - return Ok(MoveItemOutcome { - status: MoveStatus::NeedsAttention, - operation_id: Some(operation.operation_id), - session_usable: true, - }); + MoveJournalState::Dismissed => { + return Err(AgentmailError::Other(format!( + "move operation '{}' was dismissed; there is nothing to reconcile", + operation.operation_id + ))); } - _ => {} + MoveJournalState::NeedsAttention if operation.copied_uid.is_some() => { + MoveJournalState::Copied + } + MoveJournalState::NeedsAttention => MoveJournalState::CopyInFlight, + state => state, + }; + + if matches!( + resume, + MoveJournalState::Copied | MoveJournalState::DeleteInFlight + ) && let Some(reason) = missing_copy(session, &operation, whole_mailbox_visible).await? + { + return mark_needs_attention(context, operation, &reason).await; } - if let Err(error) = select_with_expected_uid_validity( + let source_exists = match open_for_reconcile( session, &operation.source_mailbox, operation.source_uid_validity, + false, ) - .await + .await? { + Opened::Gone(reason) => { + return mark_needs_attention(context, operation, &format!("source {reason}")).await; + } + Opened::Open => { + uid_present( + session, + &operation.source_mailbox, + operation.source_uid, + whole_mailbox_visible, + ) + .await? + } + }; + + // The evidence is in; a move that needed attention resumes from here. + let operation = if operation.state == MoveJournalState::NeedsAttention { + let detail = format!( + "re-examined after: {}", + operation.detail.as_deref().unwrap_or("needing attention") + ); context .journal .transition( &operation.operation_id, - &[ - MoveJournalState::Prepared, - MoveJournalState::CopyInFlight, - MoveJournalState::Copied, - MoveJournalState::DeleteInFlight, - ], - MoveJournalState::NeedsAttention, - Some(&format!("source mailbox epoch changed: {error}")), + &[MoveJournalState::NeedsAttention], + resume, + Some(&detail), ) - .await?; - return Ok(MoveItemOutcome { - status: MoveStatus::NeedsAttention, - operation_id: Some(operation.operation_id), - session_usable: true, - }); - } - let source_exists = search_uids(session, &format!("UID {}", operation.source_uid)) - .await? - .contains(&operation.source_uid); + .await? + } else { + operation + }; match operation.state { MoveJournalState::Copied | MoveJournalState::DeleteInFlight => { @@ -3092,7 +3371,10 @@ where } MoveJournalState::Complete | MoveJournalState::CopyFailed - | MoveJournalState::NeedsAttention => unreachable!("terminal states returned above"), + | MoveJournalState::NeedsAttention + | MoveJournalState::Dismissed => { + unreachable!("terminal states returned above and NeedsAttention resumed") + } } } @@ -5058,6 +5340,541 @@ mod tests { ); } + // ----- Move reconciliation: what a failed attempt may and may not decide ----- + + /// A journal in a temp directory holding one move of INBOX UID 42 (epoch + /// 7) to Archive (epoch 9, UIDNEXT 100), advanced to `state` the way the + /// move code advances it. `copied` records a COPYUID naming Archive UID 100. + async fn journaled_move( + state: crate::mutation_journal::MoveJournalState, + copied: bool, + ) -> ( + crate::mutation_journal::MutationJournal, + crate::mutation_journal::MoveOperation, + ) { + use crate::mutation_journal::{MoveJournalState, MutationJournal, PrepareMove}; + let journal = MutationJournal::at_path( + std::env::temp_dir() + .join(format!("agentmail-reconcile-{}", uuid::Uuid::new_v4())) + .join(MutationJournal::FILE_NAME), + ); + let mut operation = journal + .prepare(PrepareMove { + account_key: "acct", + source_mailbox: "INBOX", + source_uid_validity: 7, + source_uid: 42, + destination: "Archive", + destination_uid_validity: 9, + destination_uid_next: 100, + }) + .await + .expect("prepare"); + let id = operation.operation_id.clone(); + if state != MoveJournalState::Prepared { + operation = journal + .transition( + &id, + &[MoveJournalState::Prepared], + MoveJournalState::CopyInFlight, + None, + ) + .await + .expect("copy in flight"); + } + if copied { + operation = journal + .record_copied(&id, Some(9), Some(100)) + .await + .expect("copied"); + } + if state == MoveJournalState::DeleteInFlight { + operation = journal + .transition( + &id, + &[MoveJournalState::Copied], + MoveJournalState::DeleteInFlight, + None, + ) + .await + .expect("delete in flight"); + } + if state == MoveJournalState::NeedsAttention { + operation = journal + .transition( + &id, + &[operation.state], + MoveJournalState::NeedsAttention, + Some("source mailbox epoch changed: connection lost"), + ) + .await + .expect("needs attention"); + } + assert_eq!(operation.state, state); + (journal, operation) + } + + /// A scripted server for reconciliation. INBOX (UIDVALIDITY 7) holds + /// `inbox` and Archive (UIDVALIDITY 9, UIDNEXT 200) holds `archive`; + /// `UID SEARCH UID n` answers from whichever was opened last, and STATUS, + /// STORE, EXPUNGE and NOOP succeed. The first override whose marker a + /// command contains answers it instead: `None` hangs up, and `{tag}` in a + /// reply is the command's tag. + fn mail_world( + inbox: &'static [u32], + archive: &'static [u32], + overrides: &'static [(&'static str, Option<&'static str>)], + ) -> impl Fn(&str, &str) -> Option + Send + 'static { + let opened = std::sync::Mutex::new(String::new()); + move |tag, command| { + if let Some((_, reply)) = overrides + .iter() + .find(|(marker, _)| command.contains(marker)) + { + return reply.map(|reply| reply.replace("{tag}", tag)); + } + let words: Vec<&str> = command.split_whitespace().collect(); + match (words.get(1).copied(), words.get(2).copied()) { + (Some(verb @ ("SELECT" | "EXAMINE")), Some(mailbox)) => { + let mailbox = mailbox.trim_matches('"'); + let validity = if mailbox == "INBOX" { 7 } else { 9 }; + *opened.lock().expect("world lock") = mailbox.to_string(); + Some(format!( + "* 3 EXISTS\r\n* OK [UIDVALIDITY {validity}] UIDs valid\r\n\ + * OK [UIDNEXT 200] Predicted next UID\r\n{tag} OK {verb} completed\r\n" + )) + } + (Some("STATUS"), Some(mailbox)) => Some(test_support::status_reply( + tag, + mailbox.trim_matches('"'), + 9, + 200, + 3, + )), + (Some("UID"), Some("SEARCH")) => { + let uid: u32 = words.last()?.parse().ok()?; + let held = if opened.lock().expect("world lock").as_str() == "INBOX" { + inbox + } else { + archive + }; + let hit = if held.contains(&uid) { + format!(" {uid}") + } else { + String::new() + }; + Some(format!("* SEARCH{hit}\r\n{tag} OK SEARCH completed\r\n")) + } + (Some("UID"), Some("STORE")) => Some(format!("{tag} OK STORE completed\r\n")), + (Some("UID"), Some("EXPUNGE")) => { + Some(format!("* 1 EXPUNGE\r\n{tag} OK EXPUNGE completed\r\n")) + } + (Some("NOOP"), _) => Some(format!("{tag} OK NOOP completed\r\n")), + _ => None, + } + } + } + + /// Reconcile the move [`journaled_move`] builds against `server`, and + /// return what reconciliation answered, the operation as the journal now + /// holds it, and every command the server saw. + async fn reconcile_against( + server: impl Fn(&str, &str) -> Option + Send + 'static, + state: crate::mutation_journal::MoveJournalState, + copied: bool, + whole_mailbox_visible: bool, + ) -> ( + Result, + crate::mutation_journal::MoveOperation, + Vec, + ) { + let (journal, operation) = journaled_move(state, copied).await; + let (mut session, server) = test_support::scripted_session(server).await; + let outcome = reconcile_journaled_move( + &mut session, + JournalMoveContext { + journal: &journal, + account_key: "acct", + source_mailbox: "INBOX", + source_uid_validity: 7, + }, + operation.clone(), + whole_mailbox_visible, + ) + .await; + drop(session); + let commands = server.commands().await; + let after = journal + .get(&operation.operation_id) + .await + .expect("journal readable") + .expect("operation kept"); + (outcome, after, commands) + } + + fn sent(commands: &[String], fragment: &str) -> bool { + commands.iter().any(|command| command.contains(fragment)) + } + + /// A connection that drops while the source mailbox is being opened says + /// nothing about the move. Recording it as `NeedsAttention` stranded the + /// move for good, and with it any rename or delete of both mailboxes. + #[tokio::test] + async fn a_connection_lost_while_selecting_leaves_the_move_as_it_was() { + use crate::mutation_journal::MoveJournalState; + let (outcome, after, _) = reconcile_against( + mail_world(&[42], &[100], &[(" SELECT ", None)]), + MoveJournalState::Copied, + true, + true, + ) + .await; + + let error = outcome.expect_err("a lost connection decides nothing"); + assert!(error.is_connection_error(), "{error:?}"); + assert_eq!(after.state, MoveJournalState::Copied); + } + + /// The source opened, then the connection went before the search that + /// decides whether the source is still there. An empty answer from a dead + /// connection is not "gone": marking the move complete there left the + /// source behind with the journal's claim released. + #[tokio::test] + async fn a_connection_lost_after_selecting_does_not_complete_the_move() { + use crate::mutation_journal::MoveJournalState; + let (outcome, after, _) = reconcile_against( + mail_world(&[42], &[100], &[("UID SEARCH UID 42", None)]), + MoveJournalState::Copied, + true, + true, + ) + .await; + + let error = outcome.expect_err("a lost connection decides nothing"); + assert!(error.is_connection_error(), "{error:?}"); + assert_eq!(after.state, MoveJournalState::Copied); + } + + #[tokio::test] + async fn a_bye_in_place_of_the_search_answer_does_not_complete_the_move() { + use crate::mutation_journal::MoveJournalState; + let (outcome, after, _) = reconcile_against( + mail_world( + &[42], + &[100], + &[("UID SEARCH UID 42", Some("* BYE Server shutting down\r\n"))], + ), + MoveJournalState::Copied, + true, + true, + ) + .await; + + let error = outcome.expect_err("a server saying BYE answered nothing"); + assert!(error.is_connection_error(), "{error:?}"); + assert_eq!(after.state, MoveJournalState::Copied); + } + + /// A refusal that says nothing about the mailbox (here `[UNAVAILABLE]`) + /// is not evidence either: the move waits for the next attempt. + #[tokio::test] + async fn a_select_refused_for_another_reason_leaves_the_move_as_it_was() { + use crate::mutation_journal::MoveJournalState; + let (outcome, after, _) = reconcile_against( + mail_world( + &[42], + &[100], + &[( + " SELECT ", + Some("{tag} NO [UNAVAILABLE] Try again later\r\n"), + )], + ), + MoveJournalState::Copied, + true, + true, + ) + .await; + + assert!(outcome.is_err(), "{outcome:?}"); + assert_eq!(after.state, MoveJournalState::Copied); + } + + #[tokio::test] + async fn a_source_with_a_new_uidvalidity_needs_attention() { + use crate::mutation_journal::MoveJournalState; + let (outcome, after, commands) = reconcile_against( + mail_world( + &[42], + &[100], + &[( + " SELECT ", + Some( + "* OK [UIDVALIDITY 8] UIDs valid\r\n{tag} OK [READ-WRITE] SELECT completed\r\n", + ), + )], + ), + MoveJournalState::Copied, + true, + true, + ) + .await; + + assert_eq!( + outcome.expect("a live server answered").status, + crate::types::MoveStatus::NeedsAttention + ); + assert_eq!(after.state, MoveJournalState::NeedsAttention); + assert!( + after + .detail + .as_deref() + .unwrap_or("") + .contains("new UIDVALIDITY (8, was 7)"), + "{after:?}" + ); + assert!(!sent(&commands, "STORE"), "{commands:?}"); + } + + #[tokio::test] + async fn a_source_mailbox_that_no_longer_exists_needs_attention() { + use crate::mutation_journal::MoveJournalState; + let (outcome, after, _) = reconcile_against( + mail_world( + &[42], + &[100], + &[( + " SELECT ", + Some("{tag} NO [NONEXISTENT] Unknown Mailbox: INBOX\r\n"), + )], + ), + MoveJournalState::Copied, + true, + true, + ) + .await; + + assert_eq!( + outcome.expect("a live server answered").status, + crate::types::MoveStatus::NeedsAttention + ); + assert_eq!(after.state, MoveJournalState::NeedsAttention); + assert!( + after + .detail + .as_deref() + .unwrap_or("") + .contains("no longer exists"), + "{after:?}" + ); + } + + /// A SELECT without UIDVALIDITY is only the server's answer when the + /// connection is proven alive afterwards; here the NOOP succeeds. + #[tokio::test] + async fn a_live_source_without_uidvalidity_needs_attention() { + use crate::mutation_journal::MoveJournalState; + let (outcome, after, commands) = reconcile_against( + mail_world( + &[42], + &[100], + &[( + " SELECT ", + Some("* 3 EXISTS\r\n{tag} OK [READ-WRITE] SELECT completed\r\n"), + )], + ), + MoveJournalState::Copied, + true, + true, + ) + .await; + + assert_eq!( + outcome.expect("a live server answered").status, + crate::types::MoveStatus::NeedsAttention + ); + assert_eq!(after.state, MoveJournalState::NeedsAttention); + assert!(sent(&commands, " NOOP"), "{commands:?}"); + } + + #[tokio::test] + async fn a_source_gone_from_a_whole_mailbox_completes_the_move() { + use crate::mutation_journal::MoveJournalState; + let (outcome, after, commands) = reconcile_against( + mail_world(&[], &[100], &[]), + MoveJournalState::DeleteInFlight, + true, + true, + ) + .await; + + assert_eq!( + outcome.expect("reconciled").status, + crate::types::MoveStatus::Moved + ); + assert_eq!(after.state, MoveJournalState::Complete); + assert!(!sent(&commands, "EXPUNGE"), "{commands:?}"); + } + + /// A Limited Mode session can't see below its window, so a source it + /// doesn't find may still be there. + #[tokio::test] + async fn a_limited_mode_session_cannot_prove_the_source_gone() { + use crate::mutation_journal::MoveJournalState; + let (outcome, after, _) = reconcile_against( + mail_world(&[], &[100], &[]), + MoveJournalState::Copied, + true, + false, + ) + .await; + + let error = outcome.expect_err("absence proves nothing in Limited Mode"); + assert!(error.to_string().contains("Limited Mode"), "{error}"); + assert_eq!(after.state, MoveJournalState::Copied); + } + + /// The copy was deleted after the COPY — say by a person who saw the + /// message in both mailboxes. Deleting the source now would lose it. + #[tokio::test] + async fn a_copy_gone_from_the_destination_keeps_the_source() { + use crate::mutation_journal::MoveJournalState; + let (outcome, after, commands) = reconcile_against( + mail_world(&[42], &[], &[]), + MoveJournalState::Copied, + true, + true, + ) + .await; + + assert_eq!( + outcome.expect("a live server answered").status, + crate::types::MoveStatus::NeedsAttention + ); + assert_eq!(after.state, MoveJournalState::NeedsAttention); + assert!( + after + .detail + .as_deref() + .unwrap_or("") + .contains("the source was kept"), + "{after:?}" + ); + assert!(!sent(&commands, " SELECT "), "{commands:?}"); + assert!(!sent(&commands, "STORE"), "{commands:?}"); + assert!(!sent(&commands, "EXPUNGE"), "{commands:?}"); + } + + /// A move left `NeedsAttention` (here by a transient failure, before this + /// was classified) is examined again, and finishes once the evidence + /// allows: the copy is there, so is the source, and cleanup succeeds. + #[tokio::test] + async fn a_move_needing_attention_is_re_examined_and_finished() { + use crate::mutation_journal::MoveJournalState; + let (outcome, after, commands) = reconcile_against( + mail_world(&[42], &[100], &[]), + MoveJournalState::NeedsAttention, + true, + true, + ) + .await; + + assert_eq!( + outcome.expect("re-examined").status, + crate::types::MoveStatus::Moved + ); + assert_eq!(after.state, MoveJournalState::Complete); + assert!(sent(&commands, "UID STORE 42 "), "{commands:?}"); + assert!(sent(&commands, "UID EXPUNGE 42"), "{commands:?}"); + } + + /// Without a COPYUID nothing proves the copy was or wasn't made, so a + /// re-examined move asks the destination's UIDNEXT, as an ambiguous COPY + /// does. It moved on (other mail arrived), so the COPY is not sent again. + #[tokio::test] + async fn re_examining_without_a_copyuid_never_copies_twice() { + use crate::mutation_journal::MoveJournalState; + let (outcome, after, commands) = reconcile_against( + mail_world(&[42], &[], &[]), + MoveJournalState::NeedsAttention, + false, + true, + ) + .await; + + assert_eq!( + outcome.expect("a live server answered").status, + crate::types::MoveStatus::NeedsAttention + ); + assert_eq!(after.state, MoveJournalState::NeedsAttention); + assert!( + after + .detail + .as_deref() + .unwrap_or("") + .contains("UIDNEXT advanced"), + "{after:?}" + ); + assert!(!sent(&commands, "COPY"), "{commands:?}"); + assert!(!sent(&commands, "STORE"), "{commands:?}"); + } + + #[tokio::test] + async fn checked_search_returns_the_uids_and_skips_unsolicited_updates() { + let (mut session, server) = test_support::scripted_session(|tag, command| { + command + .contains("UID SEARCH") + .then(|| format!("* SEARCH 4 9\r\n* 4 EXISTS\r\n{tag} OK SEARCH completed\r\n")) + }) + .await; + + assert_eq!( + uid_search_checked(&mut session, "UID 1:9") + .await + .expect("searched"), + vec![4, 9] + ); + drop(session); + server.commands().await; + } + + #[tokio::test] + async fn checked_search_reports_a_refusal() { + let (mut session, server) = test_support::scripted_session(|tag, command| { + command + .contains("UID SEARCH") + .then(|| format!("{tag} NO [LIMIT] Too many messages\r\n")) + }) + .await; + + let error = uid_search_checked(&mut session, "ALL") + .await + .expect_err("a refusal is not an empty result"); + assert!( + matches!(error, AgentmailError::Imap(async_imap::error::Error::No(_))), + "{error:?}" + ); + drop(session); + server.commands().await; + } + + /// The bug [`uid_search_checked`] exists for, pinned against the library: + /// a search whose connection ends before any reply finds "nothing". If + /// this starts failing, async-imap surfaces end-of-stream on SEARCH. + #[tokio::test] + async fn async_imaps_own_search_reports_a_closed_stream_as_no_match() { + let (mut session, server) = test_support::scripted_session(|_, _| None).await; + + assert!( + session + .uid_search("UID 42") + .await + .is_ok_and(|found| found.is_empty()), + "async-imap 0.12 still reads end-of-stream as an empty SEARCH" + ); + drop(session); + server.commands().await; + } + /// The bug [`noop_checked`] exists for, pinned against the library: a NOOP /// whose connection ends before any reply is reported as success. If this /// starts failing, async-imap fixed `parse_noop` and the hand-written NOOP diff --git a/src/lib.rs b/src/lib.rs index d99b92a..75b26ee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2507,12 +2507,13 @@ impl Agentmail { imap_client::check_cancel(cancel)?; // Re-select each pass so a windowed server's freshly backfilled // messages become visible (a no-op once in UID Mode, where the - // whole mailbox is already visible). + // whole mailbox is already visible). A failure leaves the + // mailbox undrained, so it is reported skipped; a lost + // connection also ends the sweep (below). let mb = match imap_client::select(session, mbox).await { Ok(mb) => mb, - Err(_) => { - totals.skipped.push(mbox.clone()); - drained = true; + Err(error) => { + totals.session_usable &= !error.is_connection_error(); break; } }; @@ -2524,10 +2525,9 @@ impl Agentmail { // A discovery failure marks the mailbox skipped (coverage // incomplete) rather than aborting the account-wide sweep — // but a pending cancellation must still propagate. - Err(_) => { + Err(error) => { imap_client::check_cancel(cancel)?; - totals.skipped.push(mbox.clone()); - drained = true; + totals.session_usable &= !error.is_connection_error(); break; } }; @@ -2620,8 +2620,18 @@ impl Agentmail { } }; totals.session_usable &= session_usable; - if session_usable { - imap_client::sync(session).await?; + if totals.session_usable + && let Err(error) = imap_client::confirm_alive(session).await + { + // The batch's outcome stands (its commands completed); + // only further passes are off. + tracing::warn!( + target: "agentmail", + mailbox = mbox, + error = %error, + "connection lost after a sweep batch; stopping" + ); + totals.session_usable = false; } mailbox_found += uids.len(); mailbox_affected += affected; @@ -2629,11 +2639,33 @@ impl Agentmail { mailbox_pending += pending; mailbox_needs_attention += needs_attention; mailbox_operation_ids.extend(operation_ids); - if affected == 0 || !session_usable { + // Without a usable connection there is no next pass, so the + // mailbox may still hold matches: it stays undrained. + if !totals.session_usable { + break; + } + if affected == 0 { drained = true; break; } } + // A mailbox counts as covered only if the connection answers after + // its last command: on a closed stream SELECT and SEARCH come back + // empty, and every remaining mailbox would "drain" with nothing + // found. The same probe tells a mailbox that failed on its own + // from a connection that ended. + if totals.session_usable + && let Err(error) = imap_client::confirm_alive(session).await + { + tracing::warn!( + target: "agentmail", + mailbox = mbox, + error = %error, + "connection lost during an account-wide sweep; stopping" + ); + drained = false; + totals.session_usable = false; + } if !drained { totals.skipped.push(mbox.clone()); } @@ -2657,10 +2689,10 @@ impl Agentmail { }); } if !totals.session_usable { - // No later mailbox was attempted after an ambiguous mutation - // invalidated this connection. Report that coverage gap - // explicitly instead of returning an apparently complete - // account-wide result. + // No later mailbox was attempted after an ambiguous mutation or + // a lost connection invalidated this session. Report that + // coverage gap explicitly instead of returning an apparently + // complete account-wide result. totals .skipped .extend(mailboxes.iter().skip(mailbox_index + 1).cloned()); @@ -2767,24 +2799,28 @@ impl Agentmail { let mut total_flags: HashMap = HashMap::new(); let mut total_colors: HashMap = HashMap::new(); let mut per_mailbox = Vec::new(); + let mut skipped = Vec::new(); + let mut connection_alive = true; - for mbox in &mailboxes { + for (index, mbox) in mailboxes.iter().enumerate() { imap_client::check_cancel(cancel)?; - let scan = match imap_client::fetch_flags(session.session(), mbox, on_progress, cancel) - .await - { - Ok(s) => s, - Err(error) if mailbox.is_none() => { - tracing::warn!( - target: "agentmail", - mailbox = mbox, - error = %error, - "account-wide flag scan skipped a mailbox" - ); - continue; - } - Err(error) => return Err(error), - }; + let scanned = + imap_client::fetch_flags(session.session(), mbox, on_progress, cancel).await; + let scan = + match settle_mailbox_scan(session.session(), mbox, scanned, mailbox.is_none()) + .await? + { + MailboxScan::Counted(scan) => scan, + MailboxScan::Skipped => { + skipped.push(mbox.clone()); + continue; + } + MailboxScan::ConnectionLost => { + skipped.extend(mailboxes[index..].iter().cloned()); + connection_alive = false; + break; + } + }; if !scan.flags.is_empty() { let mbox_flags: Vec = scan @@ -2814,7 +2850,9 @@ impl Agentmail { } imap_client::check_cancel(cancel)?; - session.release().await; + if connection_alive { + session.release().await; + } let mut flag_list: Vec<(String, u32)> = total_flags.into_iter().collect(); flag_list.sort_by_key(|b| std::cmp::Reverse(b.1)); @@ -2837,6 +2875,7 @@ impl Agentmail { flags, colors, per_mailbox, + skipped, }) } @@ -3031,29 +3070,29 @@ impl Agentmail { let mut all_messages = Vec::new(); let mut per_mailbox = Vec::new(); + let mut skipped = Vec::new(); + let mut connection_alive = true; - for mbox in &mailboxes { + for (index, mbox) in mailboxes.iter().enumerate() { imap_client::check_cancel(cancel)?; - let (hits, uid_validity) = match imap_client::fetch_attachment_uids( - session.session(), - mbox, - on_progress, - cancel, - ) - .await - { - Ok(result) => result, - Err(error) if mailbox.is_none() => { - tracing::warn!( - target: "agentmail", - mailbox = mbox, - error = %error, - "account-wide attachment scan skipped a mailbox" - ); - continue; - } - Err(error) => return Err(error), - }; + let scanned = + imap_client::fetch_attachment_uids(session.session(), mbox, on_progress, cancel) + .await; + let (hits, uid_validity) = + match settle_mailbox_scan(session.session(), mbox, scanned, mailbox.is_none()) + .await? + { + MailboxScan::Counted(result) => result, + MailboxScan::Skipped => { + skipped.push(mbox.clone()); + continue; + } + MailboxScan::ConnectionLost => { + skipped.extend(mailboxes[index..].iter().cloned()); + connection_alive = false; + break; + } + }; if !hits.is_empty() { per_mailbox.push(MailboxAttachmentCount { @@ -3070,7 +3109,9 @@ impl Agentmail { } imap_client::check_cancel(cancel)?; - session.release().await; + if connection_alive { + session.release().await; + } all_messages.sort_unstable_by(|a, b| { b.date @@ -3090,6 +3131,7 @@ impl Agentmail { limit, messages, per_mailbox, + skipped, }) } @@ -3662,22 +3704,20 @@ impl Agentmail { /// explicit review. Native UID MOVE never needs journal entries. pub async fn list_pending_moves(&self, account: &str) -> Result { let account_key = self.mutation_account_key(account)?; - let operations = self - .mutation_journal - .list_pending(&account_key) - .await? - .into_iter() - .map(pending_move_from_operation) - .collect(); Ok(ListPendingMovesResponse { account: account.to_string(), - operations, + operations: self.pending_moves(&account_key).await?, }) } /// Reconcile all pending COPY-based moves for an account, or one durable /// operation ID. COPY is retried only when unchanged destination UIDNEXT - /// proves the ambiguous command did not create a message. + /// proves the ambiguous command did not create a message, and a source is + /// deleted only right after its copy has been seen. + /// + /// Operations that `NeedsAttention` are examined again. An attempt that + /// learns nothing (a dropped connection, a timeout) leaves its operation + /// as it was, counts it as `pending`, and says why in `errors`. pub async fn reconcile_moves( &self, account: &str, @@ -3688,19 +3728,10 @@ impl Agentmail { let _mutation_guard = self.lock_account_mutation(account).await; let account_key = self.mutation_account_key(account)?; let operations = if let Some(operation_id) = operation_id { - let operation = self - .mutation_journal - .get(operation_id) - .await? - .ok_or_else(|| { - AgentmailError::Other(format!("unknown move operation '{operation_id}'")) - })?; - if operation.account_key != account_key { - return Err(AgentmailError::Other(format!( - "move operation '{operation_id}' does not belong to account '{account}'" - ))); - } - vec![operation] + vec![ + self.account_move_operation(account, &account_key, operation_id) + .await?, + ] } else { self.mutation_journal.list_pending(&account_key).await? }; @@ -3710,20 +3741,38 @@ impl Agentmail { let mut pending = 0usize; let mut needs_attention = 0usize; let mut failed = 0usize; - for (index, operation) in operations.iter().cloned().enumerate() { + let mut dismissed = 0usize; + let mut errors = Vec::new(); + for (index, operation) in operations.into_iter().enumerate() { imap_client::check_cancel(cancel)?; - let mut session = self.pool.acquire(account).await?; + if operation.state == mutation_journal::MoveJournalState::Dismissed { + dismissed += 1; + if let Some(progress) = on_progress { + progress((index + 1) as u64, total); + } + continue; + } + let operation_id = operation.operation_id.clone(); let source_mailbox = operation.source_mailbox.clone(); - let outcome = imap_client::reconcile_journaled_move( - session.session(), - imap_client::JournalMoveContext { - journal: &self.mutation_journal, - account_key: &account_key, - source_mailbox: &source_mailbox, - source_uid_validity: operation.source_uid_validity, - }, - operation, - ) + let mut session = self.pool.acquire(account).await?; + let outcome = async { + let caps = self.pool.server_caps(account, session.session()).await?; + // Limited Mode (Yahoo/AOL outside UID Mode) hides messages + // below a window, so there "not found" proves nothing. + let whole_mailbox_visible = session.is_uid_mode() || !caps.has("UIDONLY"); + imap_client::reconcile_journaled_move( + session.session(), + imap_client::JournalMoveContext { + journal: &self.mutation_journal, + account_key: &account_key, + source_mailbox: &source_mailbox, + source_uid_validity: operation.source_uid_validity, + }, + operation, + whole_mailbox_visible, + ) + .await + } .await; match outcome { Ok(outcome) => { @@ -3747,25 +3796,22 @@ impl Agentmail { Err(error) => { tracing::warn!( target: "agentmail", - operation_id = operations[index].operation_id, + operation_id, error = %error, - "move reconciliation attempt failed" + "move reconciliation attempt learned nothing; operation left as it was" ); drop(session); - failed += 1; + pending += 1; + errors.push(ReconcileMoveError { + operation_id, + error: error.to_string(), + }); } } if let Some(progress) = on_progress { progress((index + 1) as u64, total); } } - let operations = self - .mutation_journal - .list_pending(&account_key) - .await? - .into_iter() - .map(pending_move_from_operation) - .collect(); Ok(ReconcileMovesResponse { account: account.to_string(), examined: usize::try_from(total).unwrap_or(usize::MAX), @@ -3773,10 +3819,80 @@ impl Agentmail { pending, needs_attention, failed, - operations, + dismissed, + errors, + operations: self.pending_moves(&account_key).await?, }) } + /// Close one pending move for good without touching either mailbox. + /// + /// For a move reconciliation can't finish — its source mailbox was + /// recreated, its copy deleted, a server answer was ambiguous — once a + /// person has checked both mailboxes and set things right by hand. Nothing + /// is moved, copied or deleted: the journal stops tracking the operation, + /// releases its claim on the source UID, and stops holding its two + /// mailboxes against rename and delete. Dismissing twice is harmless; an + /// operation that already finished can't be dismissed. + pub async fn dismiss_move( + &self, + account: &str, + operation_id: &str, + ) -> Result { + let _mutation_guard = self.lock_account_mutation(account).await; + let account_key = self.mutation_account_key(account)?; + let operation = self + .account_move_operation(account, &account_key, operation_id) + .await?; + self.mutation_journal + .dismiss(&operation.operation_id) + .await?; + Ok(ReconcileMovesResponse { + account: account.to_string(), + examined: 1, + completed: 0, + pending: 0, + needs_attention: 0, + failed: 0, + dismissed: 1, + errors: Vec::new(), + operations: self.pending_moves(&account_key).await?, + }) + } + + /// One journal operation, refused unless it belongs to `account`. + async fn account_move_operation( + &self, + account: &str, + account_key: &str, + operation_id: &str, + ) -> Result { + let operation = self + .mutation_journal + .get(operation_id) + .await? + .ok_or_else(|| { + AgentmailError::Other(format!("unknown move operation '{operation_id}'")) + })?; + if operation.account_key != account_key { + return Err(AgentmailError::Other(format!( + "move operation '{operation_id}' does not belong to account '{account}'" + ))); + } + Ok(operation) + } + + /// The account's moves still waiting on reconciliation or a person. + async fn pending_moves(&self, account_key: &str) -> Result> { + Ok(self + .mutation_journal + .list_pending(account_key) + .await? + .into_iter() + .filter_map(pending_move_from_operation) + .collect()) + } + /// Move a message to another mailbox. pub async fn move_message( &self, @@ -4665,10 +4781,14 @@ impl Agentmail { imap_client::check_cancel(cancel)?; for candidate_mailbox in &mailboxes { imap_client::check_cancel(cancel)?; + // A lost connection fails discovery outright: a graph missing + // what it could no longer search is not a preview. Other + // failures skip the mailbox with a warning. let selected = match imap_client::examine(session.session(), candidate_mailbox) .await { Ok(selected) => selected, + Err(error) if error.is_connection_error() => return Err(error), Err(error) => { push_record_warning( &mut warnings, @@ -4685,6 +4805,8 @@ impl Agentmail { ) { Ok(uid_validity) => uid_validity, Err(error) => { + // A closed stream opens every mailbox without one. + imap_client::confirm_alive(session.session()).await?; push_record_warning( &mut warnings, format!( @@ -4705,6 +4827,7 @@ impl Agentmail { .await { Ok(uids) => candidate_uids.extend(uids), + Err(error) if error.is_connection_error() => return Err(error), Err(error) => { push_record_warning( &mut warnings, @@ -4742,6 +4865,7 @@ impl Agentmail { .await { Ok(headers) => headers, + Err(error) if error.is_connection_error() => return Err(error), Err(error) => { push_record_warning( &mut warnings, @@ -4791,6 +4915,9 @@ impl Agentmail { } } } + // Every empty search above is only "no match" if the connection was + // alive when it answered; one that ended reads as empty. + imap_client::confirm_alive(session.session()).await?; session.release().await; messages.sort_by(|left, right| { @@ -5155,7 +5282,7 @@ impl Agentmail { || mailbox_is_same_or_descendant(&operation.destination, &mailbox.path, delimiter) }) { return Err(AgentmailError::Other(format!( - "mailbox '{}' is referenced by pending move {}; reconcile that operation before renaming or deleting the mailbox", + "mailbox '{}' is referenced by pending move {}; reconcile that operation, or dismiss it once you have checked both mailboxes, before renaming or deleting the mailbox", mailbox.path, operation.operation_id ))); } @@ -5495,6 +5622,67 @@ fn thread_match_basis(message: &MessageInfo, query_id: &str) -> Vec { basis } +/// What one mailbox of a scan is worth. +enum MailboxScan { + /// Scanned, and the connection proven alive afterwards. + Counted(R), + /// Failed on its own; an account-wide scan goes on without it. + Skipped, + /// The connection is gone: this mailbox and every later one go uncovered. + ConnectionLost, +} + +/// Settle one mailbox of a scan. A result counts only once the connection is +/// proven alive after it ([`imap_client::confirm_alive`]): on a closed stream +/// EXAMINE, SEARCH and FETCH come back empty or short rather than failing. A +/// single-mailbox scan (`account_wide` false) fails instead of skipping, and a +/// cancellation always propagates. +async fn settle_mailbox_scan( + session: &mut async_imap::Session, + mailbox: &str, + scanned: Result, + account_wide: bool, +) -> Result> +where + T: AsyncRead + AsyncWrite + Unpin + std::fmt::Debug + Send, +{ + let error = match scanned { + Ok(result) => match imap_client::confirm_alive(session).await { + Ok(()) => return Ok(MailboxScan::Counted(result)), + Err(error) => error, + }, + Err(error) => { + if matches!(error, AgentmailError::Cancelled) || !account_wide { + return Err(error); + } + // A mailbox that failed on a live connection is skipped on its + // own. The probe tells that apart from a connection that ended, + // whose EXAMINE "succeeds" without a UIDVALIDITY and then fails + // the scan for lack of one. + if !error.is_connection_error() && imap_client::confirm_alive(session).await.is_ok() { + tracing::warn!( + target: "agentmail", + mailbox, + error = %error, + "account-wide scan skipped a mailbox" + ); + return Ok(MailboxScan::Skipped); + } + error + } + }; + if !account_wide { + return Err(error); + } + tracing::warn!( + target: "agentmail", + mailbox, + error = %error, + "connection lost during an account-wide scan; the rest of the account is reported skipped" + ); + Ok(MailboxScan::ConnectionLost) +} + fn push_record_warning(warnings: &mut Vec, warning: String) { const MAX_WARNINGS: usize = 20; if warnings.len() < MAX_WARNINGS && !warnings.contains(&warning) { @@ -6010,18 +6198,21 @@ fn move_tallies(tallies: Vec) -> Vec { .collect() } -fn pending_move_from_operation(operation: mutation_journal::MoveOperation) -> PendingMove { +/// The pending move an active journal operation is, or `None` for one that is +/// over — complete, its COPY rejected, or dismissed — which is history. +fn pending_move_from_operation(operation: mutation_journal::MoveOperation) -> Option { use mutation_journal::MoveJournalState; let status = match operation.state { - MoveJournalState::Complete => MoveStatus::Moved, - MoveJournalState::CopyFailed => MoveStatus::Failed, MoveJournalState::NeedsAttention => MoveStatus::NeedsAttention, MoveJournalState::Prepared | MoveJournalState::CopyInFlight | MoveJournalState::Copied | MoveJournalState::DeleteInFlight => MoveStatus::ReconciliationPending, + MoveJournalState::Complete | MoveJournalState::CopyFailed | MoveJournalState::Dismissed => { + return None; + } }; - PendingMove { + Some(PendingMove { operation_id: operation.operation_id, source_mailbox: operation.source_mailbox, source_uid_validity: operation.source_uid_validity, @@ -6031,7 +6222,7 @@ fn pending_move_from_operation(operation: mutation_journal::MoveOperation) -> Pe detail: operation.detail, created_at: operation.created_at, updated_at: operation.updated_at, - } + }) } #[derive(Debug, PartialEq, Eq)] @@ -8445,4 +8636,423 @@ mod tests { "sibling-list and ordinary mail must stay put: {commands:?}" ); } + + /// An Agentmail with one account ("work") and its journal in a fresh + /// temp directory. Nothing here connects to a server. + fn journaled_agentmail() -> Agentmail { + let config = Config::from_accounts(vec![( + "work".to_string(), + AccountConfig { + host: "imap.example.com".to_string(), + port: 993, + username: "me@example.com".to_string(), + email: None, + aliases: Vec::new(), + display_name: None, + password: None, + tls: true, + max_connections: None, + auth: AuthMethod::Password, + }, + )]); + Agentmail::builder(config) + .cache_dir( + std::env::temp_dir().join(format!("agentmail-dismiss-{}", uuid::Uuid::new_v4())), + ) + .build() + } + + async fn pending_move_to_archive(mk: &Agentmail) -> mutation_journal::MoveOperation { + let account_key = mk.mutation_account_key("work").expect("configured account"); + mk.mutation_journal + .prepare(mutation_journal::PrepareMove { + account_key: &account_key, + source_mailbox: "INBOX", + source_uid_validity: 7, + source_uid: 42, + destination: "Archive", + destination_uid_validity: 9, + destination_uid_next: 100, + }) + .await + .expect("journaled") + } + + /// A move reconciliation can't finish held both its mailboxes against + /// rename and delete, and the only remedy offered was to reconcile it. + /// Dismissing it releases them, and touches no server. + #[tokio::test] + async fn a_dismissed_move_no_longer_blocks_its_mailboxes() { + let mk = journaled_agentmail(); + let operation = pending_move_to_archive(&mk).await; + let archive = imap_client::MailboxLayout { + path: "Archive".to_string(), + delimiter: Some("/".to_string()), + no_select: false, + no_inferiors: false, + roles: Vec::new(), + }; + let blocked = mk + .ensure_mailbox_not_in_pending_move("work", &archive) + .await + .expect_err("a pending move holds its destination"); + assert!( + blocked + .to_string() + .contains("or dismiss it once you have checked both mailboxes"), + "{blocked}" + ); + + let response = mk + .dismiss_move("work", &operation.operation_id) + .await + .expect("dismissed"); + + assert_eq!((response.examined, response.dismissed), (1, 1)); + assert!(response.operations.is_empty()); + mk.ensure_mailbox_not_in_pending_move("work", &archive) + .await + .expect("the destination is free again"); + let again = mk + .reconcile_moves("work", Some(&operation.operation_id), None, None) + .await + .expect("a dismissed move needs no connection"); + assert_eq!( + ( + again.examined, + again.dismissed, + again.pending, + again.errors.len() + ), + (1, 1, 0, 0) + ); + } + + #[tokio::test] + async fn a_move_is_dismissed_only_through_its_own_account() { + let mk = journaled_agentmail(); + let operation = pending_move_to_archive(&mk).await; + let other_key = "imap.example.net:993|1|3:you"; + let foreign = mk + .mutation_journal + .prepare(mutation_journal::PrepareMove { + account_key: other_key, + source_mailbox: "INBOX", + source_uid_validity: 7, + source_uid: 42, + destination: "Archive", + destination_uid_validity: 9, + destination_uid_next: 100, + }) + .await + .expect("journaled"); + + let refusal = mk + .dismiss_move("work", &foreign.operation_id) + .await + .expect_err("another account's move"); + assert!(refusal.to_string().contains("does not belong"), "{refusal}"); + assert_eq!( + mk.list_pending_moves("work") + .await + .expect("listed") + .operations[0] + .operation_id, + operation.operation_id + ); + } + + /// A connection that drops mid-sweep reads as an empty SELECT and SEARCH + /// from then on, so every later mailbox "drained" with nothing found and + /// the dead session went back to the pool. The sweep stops at the loss and + /// reports every mailbox it did not cover. + #[tokio::test] + async fn a_sweep_stops_at_a_lost_connection_and_reports_what_it_did_not_cover() { + let selected = std::sync::Mutex::new(String::new()); + let (mut session, server) = + imap_client::test_support::scripted_session(move |tag, command| { + let words: Vec<&str> = command.split_whitespace().collect(); + match (words.get(1).copied(), words.get(2).copied()) { + (Some("SELECT"), Some(mailbox)) => { + *selected.lock().expect("lock") = mailbox.trim_matches('"').to_string(); + Some(imap_client::test_support::examine_reply(tag, 9, 100, 3)) + } + // INBOX holds no match; the connection drops during + // Projects' search. + (Some("UID"), Some("SEARCH")) => (selected.lock().expect("lock").as_str() + == "INBOX") + .then(|| format!("* SEARCH\r\n{tag} OK SEARCH completed\r\n")), + (Some("NOOP"), _) => Some(format!("{tag} OK NOOP completed\r\n")), + _ => None, + } + }) + .await; + let mk = Agentmail::new(Config::empty()); + let caps = imap_client::ServerCaps::from_strings(["UIDPLUS".to_string()]); + + let totals = mk + .matching_sweep_loop( + &mut session, + "test-account", + &DeleteSelector::ListId("news.example.com".to_string()), + &[ + "INBOX".to_string(), + "Projects".to_string(), + "Clients".to_string(), + ], + SweepAction::Delete { + trash: None, + allow_permanent_fallback: false, + }, + &caps, + None, + None, + ) + .await + .expect("a lost connection ends the sweep, not its report"); + + assert_eq!( + totals.skipped, + vec!["Projects".to_string(), "Clients".to_string()] + ); + assert!(!totals.session_usable, "a dead session must not be pooled"); + drop(session); + server.commands().await; + } + + /// A scripted account for thread discovery: the seed is INBOX UID 1 + /// (`Message-ID: `), LIST shows Clients, INBOX and + /// Projects, and searches find nothing more. The connection drops at + /// the EXAMINE of `drop_at`. + fn thread_account( + drop_at: Option<&'static str>, + ) -> impl Fn(&str, &str) -> Option + Send + 'static { + move |tag, command| { + let words: Vec<&str> = command.split_whitespace().collect(); + match (words.get(1).copied(), words.get(2).copied()) { + (Some("NOOP"), _) => Some(format!("{tag} OK NOOP completed\r\n")), + (Some("LIST"), _) => Some(format!( + "* LIST (\\HasNoChildren) \"/\" \"Clients\"\r\n\ + * LIST (\\HasNoChildren) \"/\" \"INBOX\"\r\n\ + * LIST (\\HasNoChildren) \"/\" \"Projects\"\r\n\ + {tag} OK LIST completed\r\n" + )), + (Some("EXAMINE"), Some(mailbox)) => (drop_at != Some(mailbox.trim_matches('"'))) + .then(|| imap_client::test_support::examine_reply(tag, 9, 100, 3)), + (Some("UID"), Some("FETCH")) => { + let headers = "Message-ID: \r\nSubject: Hi\r\n\r\n"; + Some(format!( + "* 1 FETCH (UID 1 BODY[HEADER] {{{}}}\r\n{headers})\r\n{tag} OK FETCH completed\r\n", + headers.len() + )) + } + (Some("UID"), Some("SEARCH")) => { + Some(format!("* SEARCH\r\n{tag} OK SEARCH completed\r\n")) + } + _ => None, + } + } + } + + /// Discovery that loses its connection must fail rather than return a + /// graph missing what it could no longer search: the preview's digest + /// would bless an incomplete selection. + #[tokio::test] + async fn thread_discovery_fails_when_the_connection_is_lost() { + let (mk, server) = agentmail_on(thread_account(Some("Projects"))).await; + + let preview = mk + .preview_thread_record("INBOX", "work", 1, 9, None, None) + .await; + + let error = preview.expect_err("an incomplete graph is not a preview"); + assert!(error.is_connection_error(), "{error:?}"); + drop(mk); + server.commands().await; + } + + #[tokio::test] + async fn thread_discovery_on_a_live_connection_searches_every_mailbox() { + let (mk, server) = agentmail_on(thread_account(None)).await; + + let preview = mk + .preview_thread_record("INBOX", "work", 1, 9, None, None) + .await + .expect("previewed"); + + assert_eq!(preview.messages.len(), 1, "only the seed matches"); + assert!(preview.warnings.is_empty(), "{:?}", preview.warnings); + drop(mk); + let commands = server.commands().await; + for mailbox in ["Clients", "INBOX", "Projects"] { + assert!( + commands + .iter() + .any(|command| command.contains(&format!("EXAMINE \"{mailbox}\""))), + "{mailbox} was searched: {commands:?}" + ); + } + } + + /// A scripted account for scan tests. LIST shows Clients, INBOX and + /// Projects; each holds UID 1 (`\Seen`) and UID 2 (`\Seen \Flagged`), both + /// `multipart/mixed`. The connection drops at the first command whose + /// verb (`SEARCH`, `FETCH`) is `drop_at.1` while mailbox `drop_at.0` is + /// open, and `refuse` names a mailbox whose EXAMINE is refused. + fn scan_account( + drop_at: Option<(&'static str, &'static str)>, + refuse: Option<&'static str>, + ) -> impl Fn(&str, &str) -> Option + Send + 'static { + let opened = std::sync::Mutex::new(String::new()); + move |tag, command| { + let words: Vec<&str> = command.split_whitespace().collect(); + let (verb, argument) = (words.get(1).copied(), words.get(2).copied()); + if let (Some((mailbox, dropped_verb)), Some("UID")) = (drop_at, verb) + && argument == Some(dropped_verb) + && opened.lock().expect("lock").as_str() == mailbox + { + return None; + } + match (verb, argument) { + (Some("NOOP"), _) => Some(format!("{tag} OK NOOP completed\r\n")), + (Some("LIST"), _) => Some(format!( + "* LIST (\\HasNoChildren) \"/\" \"Clients\"\r\n\ + * LIST (\\HasNoChildren) \"/\" \"INBOX\"\r\n\ + * LIST (\\HasNoChildren) \"/\" \"Projects\"\r\n\ + {tag} OK LIST completed\r\n" + )), + (Some("EXAMINE"), Some(mailbox)) => { + let mailbox = mailbox.trim_matches('"'); + if refuse == Some(mailbox) { + return Some(format!("{tag} NO [NOPERM] Permission denied\r\n")); + } + *opened.lock().expect("lock") = mailbox.to_string(); + Some(imap_client::test_support::examine_reply(tag, 9, 100, 2)) + } + (Some("UID"), Some("SEARCH")) => { + Some(format!("* SEARCH 1 2\r\n{tag} OK SEARCH completed\r\n")) + } + (Some("UID"), Some("FETCH")) if command.contains("INTERNALDATE") => { + let header = "Content-Type: multipart/mixed; boundary=b\r\n\r\n"; + let row = |uid: u32, day: u32| { + format!( + "* {uid} FETCH (UID {uid} INTERNALDATE \"0{day}-Jan-2026 10:00:00 +0000\" BODY[HEADER.FIELDS (CONTENT-TYPE)] {{{}}}\r\n{header})\r\n", + header.len() + ) + }; + Some(format!( + "{}{}{tag} OK FETCH completed\r\n", + row(1, 1), + row(2, 2) + )) + } + (Some("UID"), Some("FETCH")) => Some(format!( + "* 1 FETCH (UID 1 FLAGS (\\Seen))\r\n\ + * 2 FETCH (UID 2 FLAGS (\\Seen \\Flagged))\r\n\ + {tag} OK FETCH completed\r\n" + )), + _ => None, + } + } + } + + /// An Agentmail whose "work" account's pool holds one scripted session. + async fn agentmail_on( + server: impl Fn(&str, &str) -> Option + Send + 'static, + ) -> (Agentmail, imap_client::test_support::ScriptedServer) { + let (session, server) = imap_client::test_support::scripted_session(server).await; + let mk = journaled_agentmail(); + mk.pool.hold_idle_for_test("work", session).await; + (mk, server) + } + + fn flag_count(response: &ListFlagsResponse, flag: &str) -> Option { + response + .flags + .iter() + .find(|count| count.flag == flag) + .map(|count| count.count) + } + + /// The connection drops while INBOX's flags are being fetched. Its empty + /// answer and every mailbox after it used to vanish from the result + /// without a trace; now they are reported as not covered. + #[tokio::test] + async fn a_flag_scan_reports_the_mailboxes_a_lost_connection_left_unscanned() { + let (mk, server) = agentmail_on(scan_account(Some(("INBOX", "FETCH")), None)).await; + + let flags = mk + .list_flags(None, "work", None, None) + .await + .expect("a lost connection ends the scan, not its report"); + + assert_eq!( + flags.skipped, + vec!["INBOX".to_string(), "Projects".to_string()] + ); + assert_eq!( + flags + .per_mailbox + .iter() + .map(|row| row.mailbox.as_str()) + .collect::>(), + vec!["Clients"] + ); + assert_eq!(flag_count(&flags, "\\Seen"), Some(2)); + assert_eq!(flag_count(&flags, "\\Flagged"), Some(1)); + drop(mk); + server.commands().await; + } + + /// One mailbox refused on a live connection is skipped on its own; the + /// scan goes on. + #[tokio::test] + async fn a_flag_scan_skips_a_refused_mailbox_and_scans_the_rest() { + let (mk, server) = agentmail_on(scan_account(None, Some("Clients"))).await; + + let flags = mk + .list_flags(None, "work", None, None) + .await + .expect("scanned"); + + assert_eq!(flags.skipped, vec!["Clients".to_string()]); + assert_eq!(flag_count(&flags, "\\Seen"), Some(4)); + drop(mk); + server.commands().await; + } + + /// Asked for one mailbox, a scan that lost its connection fails: an empty + /// answer from a dead connection is not "no flags". + #[tokio::test] + async fn a_single_mailbox_flag_scan_fails_on_a_lost_connection() { + let (mk, server) = agentmail_on(scan_account(Some(("INBOX", "FETCH")), None)).await; + + let error = mk + .list_flags(Some("INBOX"), "work", None, None) + .await + .expect_err("an empty answer from a dead connection is not a result"); + + assert!(error.is_connection_error(), "{error:?}"); + drop(mk); + server.commands().await; + } + + #[tokio::test] + async fn an_attachment_scan_reports_the_mailboxes_a_lost_connection_left_unscanned() { + let (mk, server) = agentmail_on(scan_account(Some(("INBOX", "SEARCH")), None)).await; + + let found = mk + .find_attachments(None, "work", 0, 50, None, None) + .await + .expect("a lost connection ends the scan, not its report"); + + assert_eq!( + found.skipped, + vec!["INBOX".to_string(), "Projects".to_string()] + ); + assert_eq!(found.total, 2); + assert!(found.messages.iter().all(|hit| hit.mailbox == "Clients")); + drop(mk); + server.commands().await; + } } diff --git a/src/main.rs b/src/main.rs index 7e9dfdf..5e74587 100644 --- a/src/main.rs +++ b/src/main.rs @@ -192,6 +192,10 @@ enum CliCommand { /// Operation ID from list-pending-moves; omit to reconcile all #[arg(long)] operation_id: Option, + /// Close --operation-id for good without touching either mailbox, + /// once both have been checked by hand + #[arg(long, requires = "operation_id")] + dismiss: bool, }, } @@ -451,11 +455,16 @@ async fn main() -> Result<(), Box> { CliCommand::ReconcileMoves { account, operation_id, + dismiss, } => { let mk = agentmail::Agentmail::from_default_config()?; - let value = mk - .reconcile_moves(&account, operation_id.as_deref(), None, None) - .await?; + let value = match operation_id.as_deref() { + Some(operation_id) if dismiss => mk.dismiss_move(&account, operation_id).await?, + operation_id => { + mk.reconcile_moves(&account, operation_id, None, None) + .await? + } + }; println!("{}", serde_json::to_string_pretty(&value)?); Ok(()) } @@ -1120,14 +1129,46 @@ mod tests { CliCommand::ReconcileMoves { account, operation_id, + dismiss, } => { assert_eq!(account, "work"); assert_eq!(operation_id.as_deref(), Some("operation-123")); + assert!(!dismiss); } _ => panic!("expected reconcile-moves"), } } + #[test] + fn reconcile_moves_cli_dismisses_only_a_named_operation() { + let cli = Cli::try_parse_from([ + "agentmail", + "reconcile-moves", + "--account", + "work", + "--operation-id", + "operation-123", + "--dismiss", + ]) + .expect("valid command"); + match cli.command.expect("subcommand") { + CliCommand::ReconcileMoves { dismiss, .. } => assert!(dismiss), + _ => panic!("expected reconcile-moves"), + } + + assert!( + Cli::try_parse_from([ + "agentmail", + "reconcile-moves", + "--account", + "work", + "--dismiss", + ]) + .is_err(), + "dismissing every pending move at once is not offered" + ); + } + #[test] fn private_atomic_write_replaces_complete_file() { let directory = diff --git a/src/mcp/args.rs b/src/mcp/args.rs index 2ab5f54..95aeecd 100644 --- a/src/mcp/args.rs +++ b/src/mcp/args.rs @@ -770,6 +770,11 @@ pub(super) struct ReconcileMovesArgs { description = "Optional operationId from list_pending_moves. Omit to reconcile every pending operation for the account." )] pub(super) operation_id: Option, + #[serde(default = "default_false")] + #[schemars( + description = "Close operationId for good instead of reconciling it — for a needsAttention move reconciliation can't finish, once both mailboxes have been checked and set right by hand. Moves, copies and deletes nothing; it releases the source message and both mailboxes for rename and delete. Requires operationId." + )] + pub(super) dismiss: bool, } #[derive(Debug, Deserialize, JsonSchema)] diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index f73f5db..ce96657 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -526,7 +526,9 @@ impl ServerHandler for AgentMailServer { \n\ A server without native MOVE may return `reconciliationPending` or\n\ `needsAttention` with an `operationId`. Inspect it with `list_pending_moves` and\n\ - retry with `reconcile_moves`.\n\ + retry with `reconcile_moves`, which examines `needsAttention` moves again. One it\n\ + still can't finish is closed with `reconcile_moves` `dismiss: true` and its\n\ + `operationId`, after checking both mailboxes; that moves and deletes nothing.\n\ \n\ ## Rankings\n\ \n\ diff --git a/src/mcp/tools_read.rs b/src/mcp/tools_read.rs index 6858579..a613694 100644 --- a/src/mcp/tools_read.rs +++ b/src/mcp/tools_read.rs @@ -229,7 +229,7 @@ impl AgentMailServer { #[tool( name = "list_flags", output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid list_flags output schema"), - description = "List all IMAP flags in use with counts per flag (e.g. \\Seen: 1234, \\Flagged: 56). Omit mailbox for account-wide discovery: one selectable \\All mailbox is preferred, otherwise selectable storage mailboxes are enumerated without Trash/Junk/Drafts or virtual aggregate views. Resolves Apple Mail $MailFlagBit color flags to color names (red, orange, yellow, green, blue, purple, gray).", + description = "List all IMAP flags in use with counts per flag (e.g. \\Seen: 1234, \\Flagged: 56). Omit mailbox for account-wide discovery: one selectable \\All mailbox is preferred, otherwise selectable storage mailboxes are enumerated without Trash/Junk/Drafts or virtual aggregate views. Resolves Apple Mail $MailFlagBit color flags to color names (red, orange, yellow, green, blue, purple, gray). An account-wide scan lists in skipped the mailboxes it did not cover — one it couldn't open, or every mailbox after a lost connection; their messages are in none of the counts.", annotations(title = "List Flags", read_only_hint = true), execution(task_support = "optional") )] @@ -261,7 +261,7 @@ impl AgentMailServer { #[tool( name = "find_attachments", output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid find_attachments output schema"), - description = "Find messages with attachments (multipart/mixed or multipart/related), newest-first. Each hit includes mailbox, UIDVALIDITY, UID, and date so account-wide UID collisions are unambiguous, and rides the result as a resource_link. Omit mailbox for account-wide discovery: one selectable \\All mailbox is preferred, otherwise selectable storage mailboxes are enumerated without excluded or virtual views. Defaults: offset=0, limit=25 (max 100). To save files, pass a hit's mailbox, uid, and uidValidity as expectedUidValidity to download_attachments.", + description = "Find messages with attachments (multipart/mixed or multipart/related), newest-first. Each hit includes mailbox, UIDVALIDITY, UID, and date so account-wide UID collisions are unambiguous, and rides the result as a resource_link. Omit mailbox for account-wide discovery: one selectable \\All mailbox is preferred, otherwise selectable storage mailboxes are enumerated without excluded or virtual views. Defaults: offset=0, limit=25 (max 100). To save files, pass a hit's mailbox, uid, and uidValidity as expectedUidValidity to download_attachments. An account-wide scan lists in skipped the mailboxes it did not cover; their messages are not in total.", annotations(title = "Find Attachments", read_only_hint = true), execution(task_support = "optional") )] diff --git a/src/mcp/tools_write.rs b/src/mcp/tools_write.rs index a929ccb..fb31650 100644 --- a/src/mcp/tools_write.rs +++ b/src/mcp/tools_write.rs @@ -1541,7 +1541,7 @@ impl AgentMailServer { #[tool( name = "reconcile_moves", output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid reconcile_moves output schema"), - description = "Safely reconcile durable non-native IMAP MOVE operations after a connection loss or ambiguous COPY/delete boundary. Pass one operationId from list_pending_moves, or omit it to process all pending operations for the account. The reconciler only removes a source message after it can prove the destination copy; ambiguous cases remain needsAttention instead of risking loss or duplicate deletion.", + description = "Safely reconcile durable non-native IMAP MOVE operations after a connection loss or ambiguous COPY/delete boundary. Pass one operationId from list_pending_moves, or omit it to process all pending operations for the account. A source message is removed only right after its destination copy has been seen, and COPY is retried only when the destination's UIDNEXT proves the first attempt created nothing; cases it can't prove stay needsAttention instead of risking loss or a duplicate. needsAttention operations are examined again on every call. An attempt that learns nothing (a dropped connection, a timeout) leaves its operation as it was and is listed in errors. To close a move that still can't finish, check both mailboxes, then pass its operationId with dismiss: true — that moves and deletes nothing.", annotations( title = "Reconcile Pending Moves", destructive_hint = true, @@ -1566,6 +1566,22 @@ impl AgentMailServer { None, )); } + if args.dismiss { + let Some(operation_id) = args.operation_id.as_deref() else { + return Err(McpError::invalid_params( + "dismiss requires operationId: name the one move to close", + None, + )); + }; + return match self + .agentmail + .dismiss_move(&args.account, operation_id) + .await + { + Ok(data) => compact_result(ReconcileMovesOutput::from(data)), + Err(e) => Ok(tool_error_result(&e)), + }; + } let progress = make_progress_fn(&meta, &client); let cancel = make_cancel_fn(ct); let result = self diff --git a/src/mcp/wire.rs b/src/mcp/wire.rs index 9979761..f1f9b72 100644 --- a/src/mcp/wire.rs +++ b/src/mcp/wire.rs @@ -585,6 +585,11 @@ pub(super) struct ListFlagsOutput { pub(super) per_mailbox: Vec, pub(super) per_mailbox_total: usize, pub(super) per_mailbox_truncated: bool, + /// Mailboxes the account-wide scan did not cover; their messages are in + /// none of the counts. + pub(super) skipped: Vec, + pub(super) skipped_total: usize, + pub(super) skipped_truncated: bool, } impl From for ListFlagsOutput { @@ -595,6 +600,7 @@ impl From for ListFlagsOutput { .map(MailboxFlagBreakdownOutput::from) .collect(); let (per_mailbox, per_mailbox_total, per_mailbox_truncated) = truncate_rows(per_mailbox); + let (skipped, skipped_total, skipped_truncated) = truncate_rows(value.skipped); Self { account: value.account, mailbox: value.mailbox, @@ -608,6 +614,9 @@ impl From for ListFlagsOutput { per_mailbox, per_mailbox_total, per_mailbox_truncated, + skipped, + skipped_total, + skipped_truncated, } } } @@ -649,6 +658,11 @@ pub(super) struct FindAttachmentsOutput { pub(super) per_mailbox: Vec, pub(super) per_mailbox_total: usize, pub(super) per_mailbox_truncated: bool, + /// Mailboxes the account-wide scan did not cover; their messages are not + /// in `total`. + pub(super) skipped: Vec, + pub(super) skipped_total: usize, + pub(super) skipped_truncated: bool, } impl From for FindAttachmentsOutput { @@ -675,6 +689,7 @@ impl From for FindAttachmentsOutput { }) .collect(); let (per_mailbox, per_mailbox_total, per_mailbox_truncated) = truncate_rows(per_mailbox); + let (skipped, skipped_total, skipped_truncated) = truncate_rows(value.skipped); Self { account, mailbox: value.mailbox, @@ -686,6 +701,9 @@ impl From for FindAttachmentsOutput { per_mailbox, per_mailbox_total, per_mailbox_truncated, + skipped, + skipped_total, + skipped_truncated, } } } @@ -1550,15 +1568,30 @@ impl From for ListPendingMovesOutput { impl WireOutput for ListPendingMovesOutput {} +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(inline)] +pub(super) struct ReconcileMoveErrorOutput { + pub(super) operation_id: String, + pub(super) error: String, +} + #[derive(Debug, Clone, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub(super) struct ReconcileMovesOutput { pub(super) account: String, pub(super) examined: usize, pub(super) completed: usize, + /// Still waiting on reconciliation, including every attempt in `errors`. pub(super) pending: usize, pub(super) needs_attention: usize, + /// Moves whose COPY the server rejected; the source was never touched. pub(super) failed: usize, + /// Moves closed without touching either mailbox. + pub(super) dismissed: usize, + /// Attempts that learned nothing (a dropped connection, a timeout); each + /// operation was left exactly as it was. + pub(super) errors: Vec, pub(super) operations: Vec, } @@ -1571,6 +1604,15 @@ impl From for ReconcileMovesOutput { pending: value.pending, needs_attention: value.needs_attention, failed: value.failed, + dismissed: value.dismissed, + errors: value + .errors + .into_iter() + .map(|error| ReconcileMoveErrorOutput { + operation_id: error.operation_id, + error: error.error, + }) + .collect(), operations: value .operations .into_iter() diff --git a/src/mutation_journal.rs b/src/mutation_journal.rs index 9548e90..16d14f9 100644 --- a/src/mutation_journal.rs +++ b/src/mutation_journal.rs @@ -26,8 +26,20 @@ pub(crate) enum MoveJournalState { Complete, CopyFailed, NeedsAttention, + /// Closed by a person who checked both mailboxes. Nothing was moved or + /// deleted to get here; the journal just stops tracking the operation. + Dismissed, } +/// Every state that still holds its source UID claim. +const ACTIVE_STATES: [MoveJournalState; 5] = [ + MoveJournalState::Prepared, + MoveJournalState::CopyInFlight, + MoveJournalState::Copied, + MoveJournalState::DeleteInFlight, + MoveJournalState::NeedsAttention, +]; + impl MoveJournalState { pub(crate) fn as_str(self) -> &'static str { match self { @@ -38,6 +50,7 @@ impl MoveJournalState { Self::Complete => "complete", Self::CopyFailed => "copy_failed", Self::NeedsAttention => "needs_attention", + Self::Dismissed => "dismissed", } } @@ -50,6 +63,7 @@ impl MoveJournalState { "complete" => Ok(Self::Complete), "copy_failed" => Ok(Self::CopyFailed), "needs_attention" => Ok(Self::NeedsAttention), + "dismissed" => Ok(Self::Dismissed), other => Err(rusqlite::Error::FromSqlConversionFailure( 0, rusqlite::types::Type::Text, @@ -59,14 +73,7 @@ impl MoveJournalState { } fn is_active(self) -> bool { - matches!( - self, - Self::Prepared - | Self::CopyInFlight - | Self::Copied - | Self::DeleteInFlight - | Self::NeedsAttention - ) + ACTIVE_STATES.contains(&self) } } @@ -279,6 +286,26 @@ impl MutationJournal { .await } + /// Close an active operation without touching either mailbox: it releases + /// its source UID claim and stops holding its two mailboxes against rename + /// and delete. Dismissing a dismissed operation changes nothing; one that + /// already finished (complete, or its COPY rejected) is refused, because + /// there is nothing left to dismiss. + pub(crate) async fn dismiss(&self, operation_id: &str) -> Result { + if let Some(operation) = self.get(operation_id).await? + && operation.state == MoveJournalState::Dismissed + { + return Ok(operation); + } + self.transition( + operation_id, + &ACTIVE_STATES, + MoveJournalState::Dismissed, + Some("dismissed after checking both mailboxes; nothing was moved or deleted"), + ) + .await + } + pub(crate) async fn get(&self, operation_id: &str) -> Result> { let path = self.required_path()?; let operation_id = operation_id.to_string(); @@ -525,14 +552,14 @@ fn prune_terminal_history(connection: &Connection) -> Result<()> { .to_rfc3339(); connection.execute( "DELETE FROM move_operations - WHERE state IN ('complete', 'copy_failed') AND updated_at < ?1", + WHERE state IN ('complete', 'copy_failed', 'dismissed') AND updated_at < ?1", params![cutoff], )?; connection.execute( "DELETE FROM move_operations WHERE operation_id IN ( SELECT operation_id FROM move_operations - WHERE state IN ('complete', 'copy_failed') + WHERE state IN ('complete', 'copy_failed', 'dismissed') ORDER BY updated_at DESC, operation_id DESC LIMIT -1 OFFSET ?1 )", @@ -697,6 +724,67 @@ mod tests { assert_ne!(first.operation_id, replacement.operation_id); } + fn inbox_42_to(destination: &str) -> PrepareMove<'_> { + PrepareMove { + account_key: "account", + source_mailbox: "INBOX", + source_uid_validity: 7, + source_uid: 42, + destination, + destination_uid_validity: 9, + destination_uid_next: 100, + } + } + + /// A stuck move used to hold its source UID — and both its mailboxes — + /// forever. Dismissing it releases the claim, drops it from the pending + /// list, and touches nothing else. + #[tokio::test] + async fn dismissing_a_stuck_move_releases_its_claim() { + let journal = test_journal("dismiss"); + let stuck = journal.prepare(inbox_42_to("Archive")).await.unwrap(); + journal + .transition( + &stuck.operation_id, + &[MoveJournalState::Prepared], + MoveJournalState::NeedsAttention, + Some("source disappeared before COPY"), + ) + .await + .unwrap(); + + let dismissed = journal.dismiss(&stuck.operation_id).await.unwrap(); + + assert_eq!(dismissed.state, MoveJournalState::Dismissed); + assert!(journal.list_pending("account").await.unwrap().is_empty()); + let replacement = journal.prepare(inbox_42_to("Other")).await.unwrap(); + assert_ne!(replacement.operation_id, stuck.operation_id); + } + + #[tokio::test] + async fn dismissing_twice_is_harmless_but_a_finished_move_is_refused() { + let journal = test_journal("dismiss-twice"); + let pending = journal.prepare(inbox_42_to("Archive")).await.unwrap(); + journal.dismiss(&pending.operation_id).await.unwrap(); + assert_eq!( + journal.dismiss(&pending.operation_id).await.unwrap().state, + MoveJournalState::Dismissed + ); + + let finished = journal.prepare(inbox_42_to("Archive")).await.unwrap(); + journal + .transition( + &finished.operation_id, + &[MoveJournalState::Prepared], + MoveJournalState::Complete, + None, + ) + .await + .unwrap(); + let refusal = journal.dismiss(&finished.operation_id).await.unwrap_err(); + assert!(refusal.to_string().contains("is complete"), "{refusal}"); + } + #[tokio::test] async fn only_inbox_is_case_insensitive_in_journal_identities() { let journal = test_journal("mailbox-case"); @@ -765,15 +853,33 @@ mod tests { ) .await .unwrap(); + let dismissed = journal + .prepare(PrepareMove { + account_key: "account", + source_mailbox: "INBOX", + source_uid_validity: 7, + source_uid: 44, + destination: "Archive", + destination_uid_validity: 9, + destination_uid_next: 100, + }) + .await + .unwrap(); + journal.dismiss(&dismissed.operation_id).await.unwrap(); let path = journal.required_path().unwrap(); - let operation_id = finished.operation_id.clone(); + let aged = [ + finished.operation_id.clone(), + dismissed.operation_id.clone(), + ]; run_db(path, move |connection| { let old = (Utc::now() - chrono::Duration::days(TERMINAL_HISTORY_DAYS + 1)).to_rfc3339(); - connection.execute( - "UPDATE move_operations SET updated_at = ?2 WHERE operation_id = ?1", - params![operation_id, old], - )?; + for operation_id in aged { + connection.execute( + "UPDATE move_operations SET updated_at = ?2 WHERE operation_id = ?1", + params![operation_id, old], + )?; + } Ok(()) }) .await @@ -792,5 +898,12 @@ mod tests { .await .unwrap(); assert!(journal.get(&finished.operation_id).await.unwrap().is_none()); + assert!( + journal + .get(&dismissed.operation_id) + .await + .unwrap() + .is_none() + ); } } diff --git a/src/types.rs b/src/types.rs index 0b03ee9..ec2b1a5 100644 --- a/src/types.rs +++ b/src/types.rs @@ -338,6 +338,9 @@ pub struct ListFlagsResponse { pub flags: Vec, pub colors: Vec, pub per_mailbox: Vec, + /// Mailboxes an account-wide scan did not cover — each failed, or came + /// after the connection was lost. Their messages are in none of the counts. + pub skipped: Vec, } /// A flag name with its count. @@ -379,6 +382,9 @@ pub struct FindAttachmentsResponse { pub limit: usize, pub messages: Vec, pub per_mailbox: Vec, + /// Mailboxes an account-wide scan did not cover — each failed, or came + /// after the connection was lost. Their messages are not in `total`. + pub skipped: Vec, } /// A mailbox-safe attachment search hit. @@ -847,19 +853,36 @@ pub struct ListPendingMovesResponse { pub operations: Vec, } -/// Response for reconcile_moves. +/// Response for reconcile_moves (and for dismissing one move). #[derive(Debug, Clone, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct ReconcileMovesResponse { pub account: String, pub examined: usize, pub completed: usize, + /// Still waiting on reconciliation, including every attempt in `errors`. pub pending: usize, pub needs_attention: usize, + /// Moves whose COPY the server rejected; the source was never touched. pub failed: usize, + /// Moves closed without touching either mailbox. + pub dismissed: usize, + /// Attempts that learned nothing (a dropped connection, a timeout). Each + /// operation was left exactly as it was, for the next reconcile. + pub errors: Vec, + /// The account's moves still pending after this call. pub operations: Vec, } +/// One reconciliation attempt that could not decide anything. +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(inline)] +pub struct ReconcileMoveError { + pub operation_id: String, + pub error: String, +} + /// Response for create_mailbox. #[derive(Debug, Clone, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] diff --git a/tests/mcp_integration.rs b/tests/mcp_integration.rs index 9a461fb..3f8a1cb 100644 --- a/tests/mcp_integration.rs +++ b/tests/mcp_integration.rs @@ -1972,6 +1972,8 @@ async fn reconciliation_tools_expose_durable_operation_state() { "pending", "needsAttention", "failed", + "dismissed", + "errors", "operations", ] { assert!( @@ -1979,6 +1981,68 @@ async fn reconciliation_tools_expose_durable_operation_state() { "reconcile_moves must expose `{field}`: {reconcile:#}" ); } + let error = object_schema(&reconcile["outputSchema"]["properties"]["errors"]["items"]) + .expect("reconcile error row"); + for field in ["operationId", "error"] { + assert!( + error["properties"].get(field).is_some(), + "a reconcile error must name `{field}`: {error:#}" + ); + } + let dismiss = &reconcile["inputSchema"]["properties"]["dismiss"]; + assert_eq!(dismiss["type"], json!("boolean"), "{reconcile:#}"); + let described = dismiss["description"].as_str().unwrap_or_default(); + assert!( + described.contains("Requires operationId") && described.contains("deletes nothing"), + "dismiss must say what it needs and what it leaves alone: {described}" + ); +} + +/// An account-wide scan that loses its connection, or can't open a mailbox, +/// reports what it did not cover — the sweeps always did, and `list_flags` +/// and `find_attachments` now do too. +#[tokio::test] +async fn account_wide_scans_report_the_mailboxes_they_did_not_cover() { + let mut client = McpClient::start().await; + let resp = client.request("tools/list", json!({})).await; + let tools = resp["result"]["tools"].as_array().expect("tools array"); + for name in [ + "list_flags", + "find_attachments", + "delete_by_sender", + "move_subscription", + ] { + let properties = find_tool(tools, name)["outputSchema"]["properties"] + .as_object() + .unwrap_or_else(|| panic!("`{name}` output properties")); + for field in ["skipped", "skippedTotal", "skippedTruncated"] { + assert!( + properties.contains_key(field), + "`{name}` must expose coverage field `{field}`" + ); + } + } +} + +/// Dismissing closes one named move; closing every pending move at once is +/// not offered, so `dismiss` without `operationId` fails before any work. +#[tokio::test] +async fn dismissing_a_move_requires_naming_it() { + let mut client = McpClient::start().await; + let resp = client + .request( + "tools/call", + json!({ + "name": "reconcile_moves", + "arguments": { "account": "dummy", "dismiss": true } + }), + ) + .await; + assert_eq!( + resp["error"]["code"].as_i64(), + Some(-32602), + "dismiss without operationId is invalid params: {resp:#}" + ); } #[tokio::test] From 1f5239076dbc955a3af2fdd8d0794bba3f18972b Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:03:13 -0700 Subject: [PATCH 05/25] - **#17, the NOOP after a mutation:** that NOOP now needs its own tagged `OK`. A new `release_after_mutation` replaces it at all six post-mutation sites. It returns the session to the pool only if the connection answers, and never fails the call, because the mutation's own reply is the result. A server dropping the client right after an EXPUNGE no longer turns a finished delete into an error. - **Correction to my own finding:** `update_flags` never confirmed its STOREs, so there was no "completed flag change" to misreport. Its real bugs were in reading the result back. A dropped connection came out as "message not found", and it could return another message's flags when another client's update arrived first. Both are fixed. - **#18, short LIST:** a NOOP check now follows LIST, so a list cut short by a dropped connection is never returned or cached. - **#19, reads trusting a dead connection:** this was wider than reported. Besides misleading, "message not found" from a dropped connection made the subscription move, unsubscribe and ranking samples prune the ranking cache. The cache forgot messages that still existed, breaking the samples' own documented rule that "an outage must not masquerade as deletion". - **The fix:** `with_session_retry` now probes after the read, so `search_messages`, `get_messages`, `list_identities` and the other wrapped reads retry once on a fresh connection instead of returning a partial answer. A message now counts as missing only when a live server leaves it out. I added nine tests; eight failed before the fix for the reason they target, and one is a control. The existing sample-subject test had to answer the new NOOP. The cost is one extra NOOP round trip per retried read, and per fetch that comes back missing a message. --- CHANGELOG.md | 18 ++ DECISIONS.md | 32 ++++ MCP.md | 3 + docs/plans/10-07-2027_Dangaling_Bugs.md | 14 +- src/connection.rs | 87 ++++++++- src/imap_client.rs | 234 ++++++++++++++++-------- src/lib.rs | 218 +++++++++++++++++++--- 7 files changed, 501 insertions(+), 105 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45c0f43..789b1fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and every later one in `skipped`. A single-mailbox `list_flags` or `find_attachments`, and `preview_thread_record`, fail instead of returning a partial answer. +- **A connection lost after a mutation could report it failed, or pool a dead + session.** The NOOP that follows a CREATE, delete, move, flag change or + superseded-draft cleanup was async-imap's unchecked one and was `?`-ed: a + server that dropped the client after answering turned a finished delete into + an error, and a closed stream passed and went back to the pool. That NOOP is + now checked and decides only whether the session is pooled; the mutation's + own answer stands. +- **A connection lost mid-LIST cached a short mailbox list**, and account-wide + scans then silently skipped the mailboxes it left out. LIST now counts only + once the connection answers a checked `NOOP` after it. +- **A connection lost mid-read passed for an empty or short answer.** + `search_messages`, `get_messages`, `list_identities`, `list_mailboxes` and + the other retried reads now probe the connection after reading, so a dead + socket triggers their one retry on a fresh connection instead of returning + what little arrived. A message is reported missing — and pruned from the + ranking cache, which the subscription move, unsubscribe and ranking samples + do — only when a live server leaves it out. `update_flags` reads its result + back for the message it changed, not another client's concurrent update. - **Pool and catalog timers stopped while the Mac slept.** Idle eviction, the `[LIMIT]` login cooldown and the mailbox-catalog TTL now run on the wall clock; a clock set back counts as expired. diff --git a/DECISIONS.md b/DECISIONS.md index 2e335a7..0f15714 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -161,6 +161,38 @@ in `mailboxes`, since later passes never ran. Pinned by `an_attachment_scan_reports_the_mailboxes_a_lost_connection_left_unscanned`, and `thread_discovery_fails_when_the_connection_is_lost`. +### Addendum: reads, LIST, missing messages and the NOOP after a mutation + +The same rule now covers every answer that decides something, each where its +callers already meet: + +- `ConnectionPool::with_session_retry` probes after the read. A closed stream + becomes the connection error its one retry exists for, so the read reruns on + a fresh connection instead of returning what little arrived. +- `list_mailbox_layout` probes after LIST, so the catalog never caches a short + list for scans to plan from. +- A FETCH answer that leaves out a requested UID (`answered_fetches`, + `answer_for_uid`) counts only after a probe. `MessageNotFound` is what the + subscription move, unsubscribe and the ranking samples prune the cache on, + and the samples' own rule — "an outage must not masquerade as deletion" — + was broken by an outage that doesn't look like one. +- After a mutation, `PooledSession::release_after_mutation` sends the + (now checked) `sync` NOOP and pools the session only if it answers, but + never fails: the mutation's own tagged reply is the result. Five call sites + `?`-ed the unchecked NOOP, so a server dropping the client right after an + EXPUNGE reported the finished delete as failed. + +`update_flags` differs: async-imap's STOREs don't check their tagged reply, so +its read-back is the only evidence of what landed, and a connection lost before +it is an error (repeating the idempotent update is the remedy). Pinned by +`a_read_cut_off_by_a_closed_connection_is_retried_on_a_fresh_one`, +`a_list_cut_short_by_a_closed_connection_is_an_error`, +`a_closed_connection_is_not_a_missing_message`, +`a_closed_connection_prunes_no_ranking_samples`, +`a_delete_whose_connection_closes_after_the_expunge_still_reports_it`, +`a_session_that_dies_after_a_delete_is_not_pooled`, and +`a_flag_read_back_answers_for_the_message_that_was_changed`. + ## 0.7.0 — Move Reconciliation Acts Only On A Live Server's Answer ### Decision diff --git a/MCP.md b/MCP.md index 9343f67..81831cb 100644 --- a/MCP.md +++ b/MCP.md @@ -199,6 +199,9 @@ connection ends an account-wide scan or sweep at once, and that mailbox and every later one are listed in `skipped` rather than each waiting out a timeout. A single-mailbox scan fails instead, and `preview_thread_record` fails rather than return a graph missing what it could no longer search. +Reads (`search_messages`, `get_messages`, `list_mailboxes`, `list_identities` +and the like) probe the same way and are retried once on a fresh connection, +and a message is reported missing only when a live server leaves it out. **top_senders** ```json diff --git a/docs/plans/10-07-2027_Dangaling_Bugs.md b/docs/plans/10-07-2027_Dangaling_Bugs.md index c748856..d878450 100644 --- a/docs/plans/10-07-2027_Dangaling_Bugs.md +++ b/docs/plans/10-07-2027_Dangaling_Bugs.md @@ -1,6 +1,6 @@ # 10-07-2027_Dangaling_Bugs -Here are the 16 Part 5 findings, each re-checked against today's code, with an effort level. All 16 still hold, and two came out different on the re-check (see below the table). +Here are the 16 Part 5 findings, each re-checked against today's code, with an effort level. All 16 still hold, and two came out different on the re-check (see below the table). #17–#19 turned up while fixing #1 and #2. **Effort scale:** XS = up to 2 hours · S = about half a day · M = 1–2 days · L = 3+ days. Each includes tests. @@ -22,22 +22,18 @@ Here are the 16 Part 5 findings, each re-checked against today's code, with an e | 14 | Low | `AGENTMAIL_CACHE_DIR` means `/agentmail/` for the header cache but `/` for the move journal | `header_cache.rs:1394` vs `mutation_journal.rs:117` | XS | Move the header cache (a one-time rebuild), never the journal: pending moves live there. | Done A layout test covering both files, with and without the variable. | | 15 | Upstream | async-imap 0.12 still swallows a tagged NO/BAD after SEARCH/FETCH | async-imap `parse.rs` | M | Carry a fork like the imap-proto one, plus an upstream PR. Filing the issue alone is XS. | | 16 | Docs | CHANGELOG has no 0.5/0.6 sections; ARCHITECTURE.md points at a nonexistent path | `CHANGELOG.md`, parent `ARCHITECTURE.md:266` | XS + S | There are no v0.5.0/v0.6.0 git tags, so the release boundaries have to be reconstructed from history. | Half done: the ARCHITECTURE.md path is fixed; the CHANGELOG 0.5/0.6 sections are still missing. | +| 17 | Med | `sync` (the post-mutation NOOP) is async-imap's unchecked one, and five call sites `?` it after the mutation landed: a server that drops the client right after answering turns a finished CREATE, delete, move or superseded-draft cleanup into an error, and a closed stream passes, so the dead session is pooled | `imap_client.rs` `sync`; `lib.rs` `create_mailbox`, `update_flags`, `delete_messages`, `move_message`, `discard_superseded_draft` | S | One release path for every post-mutation site. | Done. `sync` is checked; `PooledSession::release_after_mutation` pools only a session that answers and never fails the call. Different for `update_flags` than reported: its STOREs were never checked, so there was no confirmed change to misreport. Its real faults were in the read-back — `MessageNotFound` on a dropped connection, and another message's flags taken from an unsolicited FETCH — both fixed. | +| 18 | Med | `list_mailbox_layout` reads LIST to end-of-stream, so a connection lost mid-LIST caches a partial mailbox catalog for its TTL, and account-wide scans silently skip the missing mailboxes | `imap_client.rs` `list_mailbox_layout`, `mailbox_catalog.rs` | S | Every LIST goes through one function. | Done. A checked NOOP follows the LIST; a short list is never returned or cached. | +| 19 | Med | Single-mailbox reads (`search_messages`, `get_messages`, `list_identities`) still take an empty or short SEARCH/FETCH from a closed stream as the answer | `connection.rs` `with_session_retry`; `imap_client.rs` FETCH-by-UID helpers; `lib.rs` `sample_subjects` | S | Probe where the reads already meet: the retry wrapper, and the comparison of requested and answered UIDs. | Done. `with_session_retry` probes after the read, so a closed stream triggers its one retry on a fresh connection. A missing UID counts only after a probe, everywhere a single message is read. | **Fixed alongside (not in the 16):** - Attachment names past a filesystem's 255-byte limit could not be downloaded; the canonical name is now capped at 240 bytes for both the download and `/info`. - `to`/`cc`/`bcc`/`replyTo` document `Name
`; Mail Accounts settings offer the account's Sent From addresses as aliases. -**New findings while fixing #1 and #2 (reported, not fixed):** - -| # | Sev | Finding | Where | Effort | -|---|---|---|---|---| -| 17 | Med | `sync` (the post-mutation NOOP) is async-imap's unchecked one, and five call sites `?` it after the mutation landed: a timeout there reports a completed CREATE, flag change, delete, move or superseded-draft cleanup as failed. On a closed stream it passes, so the dead session is pooled (the next acquire's ping catches it). | `imap_client.rs` `sync`; `lib.rs` `create_mailbox`, `update_flags`, `delete_messages`, `move_message`, `discard_superseded_draft` | S | -| 18 | Med | `list_mailbox_layout` reads LIST to end-of-stream, so a connection lost mid-LIST caches a partial mailbox catalog for its TTL, and account-wide scans silently skip the missing mailboxes. | `imap_client.rs` `list_mailbox_layout`, `mailbox_catalog.rs` | S | -| 19 | Low | Single-mailbox reads (`search_messages`, `get_messages`, `list_identities`) still take an empty or short SEARCH/FETCH from a closed stream as the answer. Same root as #15; `confirm_alive` after the read would close it per call. | `lib.rs` | S | - **Changed on re-check:** - **#2 is worse than reported, so I've raised it to High.** A stuck move also blocks renaming or deleting both its source and destination mailboxes. The only remedy offered is "reconcile that operation", and reconcile returns `NeedsAttention` immediately. So one transient connection drop can lock two mailboxes for good. - **#3 is narrower.** A `[LIMIT]` reply itself is already exempt from retry; only the auth-failure retries remain. +- **#19 is wider than reported, so I've raised it from Low to Med.** "Message not found" from a closed stream did more than mislead: the subscription move, unsubscribe and the ranking samples prune the ranking cache on it, so a dropped connection made the cache forget messages that still existed. The samples' code even documents "an outage must not masquerade as deletion". **Suggested order:** 1. The quick wins: #9, #12, #14, the ARCHITECTURE.md path, #5, #7, #8, #13. diff --git a/src/connection.rs b/src/connection.rs index e1fe3a4..756afab 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -640,6 +640,16 @@ impl ConnectionPool { }); } + /// How many idle connections `account_name` holds, in either store. + #[cfg(test)] + pub(crate) async fn idle_sessions(&self, account_name: &str) -> usize { + let mut count = 0; + for store in [&self.pools, &self.uid_pools] { + count += store.lock().await.get(account_name).map_or(0, Vec::len); + } + count + } + async fn pop_idle_any(&self, account_name: &str) -> Option<(ImapSession, bool)> { for (store, uid_mode) in [(&self.uid_pools, true), (&self.pools, false)] { let maybe_idle = { @@ -886,15 +896,25 @@ impl ConnectionPool { /// retry). This closes the connection-died-*during*-an-op race that the /// idle-TTL eviction can't see. Control flow lives in the `retry_once!` /// macro, which is unit-tested without real sessions. + /// + /// An answer counts only once the connection answers a NOOP after it — one + /// that passes only on its own tagged OK: async-imap ends SELECT, SEARCH, FETCH + /// and LIST quietly on a closed stream, so a socket that died mid-read + /// returns an empty or short `Ok` rather than the error this retry is for. + /// The probe turns it into that error. pub async fn with_session_retry( &self, account: &str, - op: impl AsyncFnMut(&mut ImapSession) -> crate::Result, + mut op: impl AsyncFnMut(&mut ImapSession) -> crate::Result, ) -> crate::Result { retry_once!( || self.acquire(account), PooledSession::session, - op, + async |session: &mut ImapSession| -> crate::Result { + let answer = op(session).await?; + imap_client::confirm_alive(session).await?; + Ok(answer) + }, PooledSession::release, ) } @@ -957,6 +977,23 @@ impl PooledSession { self.uid_mode = true; } + /// Return the session to the pool after a mutation, once a checked NOOP + /// ([`imap_client::sync`]) has flushed the server's queued updates and + /// proven the connection alive; one that doesn't answer is closed instead. + /// Never fails: the mutation's own commands were answered, so its result + /// stands whatever becomes of the connection afterwards. + pub async fn release_after_mutation(mut self) { + match imap_client::sync(self.session()).await { + Ok(()) => self.release().await, + Err(error) => tracing::debug!( + target: "agentmail", + account = %self.account_name, + error = %error, + "connection did not answer after a mutation; closing it instead of pooling it" + ), + } + } + /// Return the session to the pool for reuse — the UID-Mode store when this /// connection has UIDONLY enabled, the Limited pool otherwise. /// The concurrency permit is released when this PooledSession is dropped. @@ -1517,6 +1554,52 @@ mod tests { ); } + /// A read on a closed stream comes back empty instead of failing, so + /// `with_session_retry` never saw the drop it exists to retry, and the + /// empty answer stood. Probing the connection after the read turns it + /// into the connection error it is, and the read runs again on a fresh + /// connection. + #[tokio::test] + async fn a_read_cut_off_by_a_closed_connection_is_retried_on_a_fresh_one() { + let noop = |tag: &str| format!("{tag} OK NOOP completed\r\n"); + let (healthy, healthy_server) = + imap_client::test_support::scripted_session(move |tag, command| { + if command.contains(" NOOP") { + Some(noop(tag)) + } else if command.contains("UID SEARCH") { + Some(format!("* SEARCH 4 9\r\n{tag} OK SEARCH completed\r\n")) + } else { + None + } + }) + .await; + // Answers the pool's ping, then drops the connection at the SEARCH. + let (dying, dying_server) = + imap_client::test_support::scripted_session(move |tag, command| { + command.contains(" NOOP").then(|| noop(tag)) + }) + .await; + let pool = ConnectionPool::new(Config::from_accounts(vec![( + "work".to_string(), + AccountConfig::new("imap.example.com", "me@example.com"), + )])); + pool.hold_idle_for_test("work", healthy).await; + pool.hold_idle_for_test("work", dying).await; // handed out first + + let mut uids = pool + .with_session_retry("work", async |session| { + imap_client::search_uids(session, "ALL").await + }) + .await + .expect("the read is retried, not answered empty"); + + uids.sort_unstable(); + assert_eq!(uids, vec![4, 9]); + drop(pool); + dying_server.commands().await; + healthy_server.commands().await; + } + /// Handle whose release-vs-drop fate is observable: the flag flips only if /// the release path ran; a plain drop leaves it false. struct TestHandle { diff --git a/src/imap_client.rs b/src/imap_client.rs index 98dd028..9466f5f 100644 --- a/src/imap_client.rs +++ b/src/imap_client.rs @@ -97,6 +97,11 @@ pub(crate) mod test_support { use super::{ImapSession, ImapTransport}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + /// Ends a scripted reply when the server should send it and then close the + /// connection, so the client's next command fails to send — what a server + /// does when it drops a client right after answering it. + pub(crate) const THEN_HANG_UP: &str = "\u{0}then hang up"; + /// A running scripted server. Drop the session, then call /// [`ScriptedServer::commands`] to collect every line the client sent. pub(crate) struct ScriptedServer { @@ -141,7 +146,11 @@ pub(crate) mod test_support { respond(&tag, &command) }; let Some(reply) = reply else { break }; - if writer.write_all(reply.as_bytes()).await.is_err() { + let (reply, hang_up) = match reply.strip_suffix(THEN_HANG_UP) { + Some(reply) => (reply.to_string(), true), + None => (reply, false), + }; + if writer.write_all(reply.as_bytes()).await.is_err() || hang_up { break; } } @@ -300,7 +309,7 @@ where /// item that carries a fetched section, else its first item. An item is never /// dropped for lacking a section alone — a requested message whose section came /// back `NIL` still answers, and callers that prune unanswered UIDs rely on it. -fn answering_fetches( +pub(crate) fn answering_fetches( fetched: Vec>, requested: &[u32], ) -> Result> { @@ -330,6 +339,47 @@ fn answering_fetches( Ok(answers) } +/// [`answering_fetches`], trusted only from a live connection. +/// +/// An answer that leaves out a requested UID means the message is gone — or +/// that the stream closed, since async-imap ends a FETCH quietly at +/// end-of-stream. Callers report "no longer exists" and prune caches on a +/// missing UID, so a short answer counts only once a NOOP proves the +/// connection alive; otherwise the lost connection is the error. +async fn answered_fetches( + session: &mut Session, + fetched: Vec>, + requested: &[u32], +) -> Result> +where + T: AsyncRead + AsyncWrite + Unpin + fmt::Debug + Send, +{ + let answers = answering_fetches(fetched, requested)?; + let distinct: hashbrown::HashSet = requested.iter().copied().collect(); + if answers.len() < distinct.len() { + confirm_alive(session).await?; + } + Ok(answers) +} + +/// The FETCH item answering `uid`, or `MessageNotFound` once a live +/// connection has shown the message gone (see [`answered_fetches`]). +async fn answer_for_uid( + session: &mut Session, + fetched: Vec>, + uid: u32, +) -> Result +where + T: AsyncRead + AsyncWrite + Unpin + fmt::Debug + Send, +{ + answered_fetches(session, fetched, &[uid]) + .await? + .into_iter() + .next() + .map(|(_, fetch)| fetch) + .ok_or(AgentmailError::MessageNotFound(uid)) +} + /// Select a mailbox with timeout. Use this instead of calling `session.select()` directly. /// Yahoo/AOL intermittently answer SELECT/EXAMINE with /// `NO [SERVERBUG] ... Please try again later`. It is genuinely transient; @@ -1326,6 +1376,9 @@ pub(crate) async fn list_mailbox_layout(session: &mut ImapSession) -> Result(stream.collect::>().await) }) .await?; + // LIST also ends quietly at end-of-stream, and a short list is cached by + // the mailbox catalog for every account-wide scan to plan from. + confirm_alive(session).await?; let mut result = Vec::with_capacity(names.len()); for item in names { @@ -1771,12 +1824,7 @@ pub async fn fetch_sender(session: &mut ImapSession, uid: u32) -> Result<(String let uid_str = uid.to_string(); let fetched = timed_uid_fetch_collect(session, &uid_str, "BODY.PEEK[HEADER.FIELDS (FROM)]").await?; - - let fetch = fetched - .into_iter() - .next() - .ok_or(AgentmailError::MessageNotFound(uid))? - .map_err(AgentmailError::Imap)?; + let fetch = answer_for_uid(session, fetched, uid).await?; let header_bytes = fetch.header().unwrap_or(&[]); let (email, name, _date, _msgid) = parser::parse_sender_date(header_bytes)?; @@ -2018,7 +2066,7 @@ async fn fetch_rank_header_rows( let fetched = timed_uid_fetch_collect(session, &uid_set, items).await?; - for (uid, fetch) in answering_fetches(fetched, chunk)? { + for (uid, fetch) in answered_fetches(session, fetched, chunk).await? { let header_bytes = fetch.header().unwrap_or(&[]); let header_str = String::from_utf8_lossy(header_bytes); @@ -2092,7 +2140,7 @@ where "(UID BODY.PEEK[HEADER.FIELDS (List-Id)])", ) .await?; - for (uid, fetch) in answering_fetches(fetched, chunk)? { + for (uid, fetch) in answered_fetches(session, fetched, chunk).await? { let header_str = String::from_utf8_lossy(fetch.header().unwrap_or(&[])); results.push((uid, extract_header_value(&header_str, "List-Id"))); } @@ -2138,7 +2186,7 @@ pub async fn fetch_by_uids( // Extract owned data from the IMAP fetch results so we can parse off-thread let mut raw_items: RawFetchItems = Vec::with_capacity(fetched.len()); - for (uid, fetch) in answering_fetches(fetched, uids)? { + for (uid, fetch) in answered_fetches(session, fetched, uids).await? { let size = fetch.size; if include_content && size.is_none_or(|value| value as usize > MAX_TRANSIENT_MESSAGE_BYTES) { @@ -2210,11 +2258,7 @@ pub async fn fetch_by_uids( pub async fn get_flags(session: &mut ImapSession, uid: u32) -> Result> { let uid_str = uid.to_string(); let fetched = timed_uid_fetch_collect(session, &uid_str, "(FLAGS)").await?; - let fetch = fetched - .into_iter() - .next() - .ok_or(AgentmailError::MessageNotFound(uid))? - .map_err(AgentmailError::Imap)?; + let fetch = answer_for_uid(session, fetched, uid).await?; Ok(fetch.flags().map(|f| flag_to_string(&f)).collect()) } @@ -2274,14 +2318,14 @@ pub async fn remove_flags(session: &mut ImapSession, uid: u32, flags: &[String]) // --------------------------------------------------------------------------- /// Flush pending server-side state after a mutation (EXPUNGE, EXISTS, etc.). -/// Issues NOOP which forces the server to send any queued untagged responses, -/// ensuring the session view is up-to-date before release back to the pool. +/// Issues a NOOP, which makes the server send any queued untagged responses, +/// and passes only on its own tagged OK — async-imap's `noop()` would pass a +/// closed stream, and its session would go back to the pool dead. pub async fn sync(session: &mut Session) -> Result<()> where T: AsyncRead + AsyncWrite + Unpin + fmt::Debug + Send, { - imap_timeout(session.noop()).await?; - Ok(()) + confirm_alive(session).await } // --------------------------------------------------------------------------- @@ -3679,15 +3723,11 @@ where examine_with_expected_uid_validity(session, mailbox, expected_uid_validity).await?; let uid_str = uid.to_string(); let metadata = timed_uid_fetch_collect(session, &uid_str, "(UID RFC822.SIZE)").await?; - let mut size = None; - for item in metadata { - let fetch = item.map_err(AgentmailError::Imap)?; - if fetch.uid == Some(uid) { - size = fetch.size.map(|value| value as usize); - break; - } - } - let size = size.ok_or(AgentmailError::MessageNotFound(uid))?; + let size = answer_for_uid(session, metadata, uid) + .await? + .size + .map(|value| value as usize) + .ok_or(AgentmailError::MessageNotFound(uid))?; if size > max_bytes { return Err(AgentmailError::Other(format!( "message UID {uid} is {size} bytes; this resource is limited to {max_bytes} bytes" @@ -3696,16 +3736,11 @@ where let fetch_items = format!("(UID BODY.PEEK[]<0.{}>)", max_bytes.saturating_add(1)); let fetched = timed_uid_fetch_collect(session, &uid_str, &fetch_items).await?; - - let mut body = None; - for item in fetched { - let fetch = item.map_err(AgentmailError::Imap)?; - if fetch.uid == Some(uid) { - body = fetch.body().map(<[u8]>::to_vec); - break; - } - } - let body = body.ok_or(AgentmailError::MessageNotFound(uid))?; + let body = answer_for_uid(session, fetched, uid) + .await? + .body() + .map(<[u8]>::to_vec) + .ok_or(AgentmailError::MessageNotFound(uid))?; if body.len() > max_bytes || body.len() != size { return Err(AgentmailError::Other(format!( "message UID {uid} source was truncated or exceeded the {max_bytes} byte resource limit" @@ -3728,15 +3763,12 @@ where examine_with_expected_uid_validity(session, mailbox, expected_uid_validity).await?; let fetch_items = format!("(UID BODY.PEEK[HEADER]<0.{}>)", max_bytes.saturating_add(1)); let fetched = timed_uid_fetch_collect(session, &uid.to_string(), &fetch_items).await?; - let mut headers = None; - for item in fetched { - let fetch = item.map_err(AgentmailError::Imap)?; - if fetch.uid == Some(uid) { - headers = fetch.header().or_else(|| fetch.body()).map(<[u8]>::to_vec); - break; - } - } - let headers = headers.ok_or(AgentmailError::MessageNotFound(uid))?; + let fetch = answer_for_uid(session, fetched, uid).await?; + let headers = fetch + .header() + .or_else(|| fetch.body()) + .map(<[u8]>::to_vec) + .ok_or(AgentmailError::MessageNotFound(uid))?; if headers.len() > max_bytes { return Err(AgentmailError::Other(format!( "message UID {uid} headers exceed the {max_bytes} byte resource limit" @@ -3798,15 +3830,8 @@ where let uid_set = uid.to_string(); check_cancel(cancel)?; let metadata = timed_uid_fetch_collect(session, &uid_set, "(UID RFC822.SIZE)").await?; - let mut expected_size = None; - for item in metadata { - let fetch = item.map_err(AgentmailError::Imap)?; - if fetch.uid == Some(uid) { - expected_size = Some(validate_unsubscribe_message_size(uid, fetch.size)?); - break; - } - } - let expected_size = expected_size.ok_or(AgentmailError::MessageNotFound(uid))?; + let expected_size = + validate_unsubscribe_message_size(uid, answer_for_uid(session, metadata, uid).await?.size)?; check_cancel(cancel)?; // Request at most limit+1 octets. If a server understates RFC822.SIZE, the @@ -3814,15 +3839,11 @@ where // this matters for DKIM signatures that use an `l=` body-length tag. let body_items = format!("(UID BODY.PEEK[]<0.{}>)", MAX_UNSUBSCRIBE_MESSAGE_BYTES + 1); let fetched = timed_uid_fetch_collect(session, &uid_set, &body_items).await?; - let mut raw_message = None; - for item in fetched { - let fetch = item.map_err(AgentmailError::Imap)?; - if fetch.uid == Some(uid) { - raw_message = fetch.body().map(<[u8]>::to_vec); - break; - } - } - let raw_message = raw_message.ok_or(AgentmailError::MessageNotFound(uid))?; + let raw_message = answer_for_uid(session, fetched, uid) + .await? + .body() + .map(<[u8]>::to_vec) + .ok_or(AgentmailError::MessageNotFound(uid))?; // Yahoo/AOL's RFC822.SIZE is unreliable metadata — observed deltas run // in BOTH directions (line-ending/charset accounting at ingestion), so a // mismatch is not evidence of truncation. The only case where a clipped @@ -3870,12 +3891,7 @@ where "BODY.PEEK[HEADER.FIELDS (List-Unsubscribe List-Unsubscribe-Post List-Id)]", ) .await?; - - let fetch = fetched - .into_iter() - .next() - .ok_or(AgentmailError::MessageNotFound(uid))? - .map_err(AgentmailError::Imap)?; + let fetch = answer_for_uid(session, fetched, uid).await?; let header_bytes = fetch.header().unwrap_or(&[]); let header_str = String::from_utf8_lossy(header_bytes); @@ -5875,6 +5891,80 @@ mod tests { server.commands().await; } + /// A connection that closes partway through LIST hands back a short list, + /// which the mailbox catalog caches and every account-wide scan then + /// plans from — skipping the mailboxes it never heard about. + #[tokio::test] + async fn a_list_cut_short_by_a_closed_connection_is_an_error() { + let (mut session, server) = test_support::scripted_session(|_, command| { + command.contains(" LIST ").then(|| { + format!( + "* LIST (\\HasNoChildren) \"/\" \"INBOX\"\r\n{}", + test_support::THEN_HANG_UP + ) + }) + }) + .await; + + let error = list_mailbox_layout(&mut session) + .await + .expect_err("a list without its tagged OK is not the account's mailboxes"); + + assert!(error.is_connection_error(), "{error:?}"); + drop(session); + server.commands().await; + } + + /// A FETCH on a closed stream returns no row. Read as "the message no + /// longer exists", it made callers prune the ranking cache of a message + /// that is still there. + #[tokio::test] + async fn a_closed_connection_is_not_a_missing_message() { + let (mut session, server) = test_support::scripted_session(|tag, command| { + command + .contains("EXAMINE") + .then(|| test_support::examine_reply(tag, 9, 100, 3)) + }) + .await; + + let error = get_message_headers_bounded(&mut session, "INBOX", 7, 9, 4096) + .await + .expect_err("no answer came"); + + assert!(error.is_connection_error(), "{error:?}"); + drop(session); + server.commands().await; + } + + /// The other side of the same rule: a live server that answers without + /// the UID still means the message is gone. + #[tokio::test] + async fn a_live_server_without_the_row_reports_the_message_missing() { + let (mut session, server) = test_support::scripted_session(|tag, command| { + if command.contains("EXAMINE") { + Some(test_support::examine_reply(tag, 9, 100, 3)) + } else if command.contains("UID FETCH") { + Some(format!("{tag} OK FETCH completed\r\n")) + } else if command.contains(" NOOP") { + Some(format!("{tag} OK NOOP completed\r\n")) + } else { + None + } + }) + .await; + + let error = get_message_headers_bounded(&mut session, "INBOX", 7, 9, 4096) + .await + .expect_err("the server has no UID 7"); + + assert!( + matches!(error, AgentmailError::MessageNotFound(7)), + "{error:?}" + ); + drop(session); + server.commands().await; + } + /// The bug [`noop_checked`] exists for, pinned against the library: a NOOP /// whose connection ends before any reply is reported as success. If this /// starts failing, async-imap fixed `parse_noop` and the hand-written NOOP diff --git a/src/lib.rs b/src/lib.rs index 75b26ee..0bc4719 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -716,8 +716,7 @@ impl Agentmail { // Fence a catalog refresh that raced the server-side CREATE. self.invalidate_mailbox_catalog(account); create_result?; - imap_client::sync(session.session()).await?; - session.release().await; + session.release_after_mutation().await; Ok(CreateMailboxResponse { account: account.to_string(), @@ -3021,10 +3020,13 @@ impl Agentmail { imap_client::add_flags(session.session(), uid, add).await?; } - imap_client::sync(session.session()).await?; + // The read-back is the result: async-imap's STOREs don't check their + // tagged reply, so these flags are the only evidence of what landed. + // A connection lost before they arrive is reported as that — never as + // a missing message — and repeating the idempotent update is the fix. let updated_flags = imap_client::get_flags(session.session(), uid).await?; let resolved_color = bits_to_color(&updated_flags).map(|c| c.to_string()); - session.release().await; + session.release_after_mutation().await; Ok(UpdateFlagsResponse { mailbox: mailbox.to_string(), @@ -3200,8 +3202,7 @@ impl Agentmail { ) .await?; if result.session_usable { - imap_client::sync(session.session()).await?; - session.release().await; + session.release_after_mutation().await; } else { drop(session); } @@ -3783,12 +3784,7 @@ impl Agentmail { MoveStatus::NeedsAttention => needs_attention += 1, } if outcome.session_usable { - let sync = imap_client::sync(session.session()).await; - if sync.is_ok() { - session.release().await; - } else { - drop(session); - } + session.release_after_mutation().await; } else { drop(session); } @@ -3942,8 +3938,7 @@ impl Agentmail { ) .await?; if outcome.session_usable { - imap_client::sync(session.session()).await?; - session.release().await; + session.release_after_mutation().await; } else { drop(session); } @@ -4497,8 +4492,7 @@ impl Agentmail { ) .await?; if result.session_usable { - imap_client::sync(session.session()).await?; - session.release().await; + session.release_after_mutation().await; } else { drop(session); } @@ -6535,21 +6529,34 @@ where else { continue; }; + let Ok(answers) = imap_client::answering_fetches(fetched, &uids) else { + continue; + }; // Which requested UIDs the server acknowledged with a row at all — // a row with no Subject header still proves the message is alive. let mut returned: hashbrown::HashSet = hashbrown::HashSet::new(); - for item in fetched { - let Ok(fetch) = item else { continue }; - let Some(uid) = fetch.uid else { continue }; + for (uid, fetch) in answers { returned.insert(uid); if let Some(subject) = fetch.header().and_then(parser::parse_subject) { subjects.insert((mailbox.to_string(), expected_uid_validity, uid), subject); } } + let absent: Vec = uids + .iter() + .copied() + .filter(|uid| !returned.contains(uid)) + .collect(); + // An absent row means deleted only from a live connection: on a + // closed stream the FETCH ends quietly with no rows, and every sample + // would be pruned. The rows that did arrive stand either way; a failed + // probe means there is nothing more to ask this connection. + if !absent.is_empty() && imap_client::confirm_alive(session).await.is_err() { + break; + } missing.extend( - uids.iter() - .filter(|uid| !returned.contains(*uid)) - .map(|uid| (mailbox.to_string(), expected_uid_validity, *uid)), + absent + .into_iter() + .map(|uid| (mailbox.to_string(), expected_uid_validity, uid)), ); } SampleSubjects { subjects, missing } @@ -8338,6 +8345,9 @@ mod tests { plain.len(), encoded.len() ) + } else if line.contains("NOOP") { + // The absent UID 9 is checked against a live connection. + format!("{tag} OK NOOP completed\r\n") } else { panic!("unexpected command: {line:?}"); }; @@ -9055,4 +9065,168 @@ mod tests { drop(mk); server.commands().await; } + + /// A scripted INBOX (UIDVALIDITY 9) for a permanent delete of UID 7. The + /// first NOOP is the pool's ping on acquire; the one after the delete gets + /// no answer. With `close_after_expunge`, the server instead closes the + /// connection as soon as it has answered the EXPUNGE. + fn permanent_delete_account( + close_after_expunge: bool, + ) -> impl Fn(&str, &str) -> Option + Send + 'static { + let noops = std::sync::atomic::AtomicUsize::new(0); + move |tag, command| { + let words: Vec<&str> = command.split_whitespace().collect(); + match (words.get(1).copied(), words.get(2).copied()) { + (Some("NOOP"), _) => (noops.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0) + .then(|| format!("{tag} OK NOOP completed\r\n")), + (Some("CAPABILITY"), _) => Some(format!( + "* CAPABILITY IMAP4rev1 UIDPLUS\r\n{tag} OK CAPABILITY completed\r\n" + )), + (Some("SELECT"), _) => { + Some(imap_client::test_support::examine_reply(tag, 9, 100, 3)) + } + (Some("UID"), Some("STORE")) => Some(format!("{tag} OK STORE completed\r\n")), + (Some("UID"), Some("EXPUNGE")) => Some(format!( + "* 1 EXPUNGE\r\n{tag} OK EXPUNGE completed\r\n{}", + if close_after_expunge { + imap_client::test_support::THEN_HANG_UP + } else { + "" + } + )), + _ => None, + } + } + } + + async fn delete_uid_7(mk: &Agentmail) -> Result { + mk.delete_messages("INBOX", "work", &[7], 9, DeleteMode::Permanent, None, None) + .await + } + + /// The EXPUNGE was answered OK, so the message is gone; a connection that + /// closes right after must not turn that into a failed delete. + #[tokio::test] + async fn a_delete_whose_connection_closes_after_the_expunge_still_reports_it() { + let (mk, server) = agentmail_on(permanent_delete_account(true)).await; + + let deleted = delete_uid_7(&mk) + .await + .expect("the delete happened; only the connection is gone"); + + assert_eq!(deleted.deleted, 1); + assert_eq!(mk.pool.idle_sessions("work").await, 0); + drop(mk); + server.commands().await; + } + + /// The NOOP after a delete passed on a closed stream, so the dead + /// connection went back to the pool for the next caller to trip over. + #[tokio::test] + async fn a_session_that_dies_after_a_delete_is_not_pooled() { + let (mk, server) = agentmail_on(permanent_delete_account(false)).await; + + let deleted = delete_uid_7(&mk).await.expect("deleted"); + + assert_eq!(deleted.deleted, 1); + assert_eq!( + mk.pool.idle_sessions("work").await, + 0, + "a connection that did not answer is closed, not pooled" + ); + drop(mk); + server.commands().await; + } + + /// INBOX (UIDVALIDITY 9) for `update_flags` on UID 7: SELECT, STORE and + /// NOOP succeed, and `flags_reply` answers the read-back FETCH (`None` + /// hangs up). + fn flag_account( + flags_reply: Option<&'static str>, + ) -> impl Fn(&str, &str) -> Option + Send + 'static { + move |tag, command| { + let words: Vec<&str> = command.split_whitespace().collect(); + match (words.get(1).copied(), words.get(2).copied()) { + (Some("NOOP"), _) => Some(format!("{tag} OK NOOP completed\r\n")), + (Some("SELECT"), _) => { + Some(imap_client::test_support::examine_reply(tag, 9, 100, 3)) + } + (Some("UID"), Some("STORE")) => Some(format!("{tag} OK STORE completed\r\n")), + (Some("UID"), Some("FETCH")) => { + flags_reply.map(|rows| format!("{rows}{tag} OK FETCH completed\r\n")) + } + _ => None, + } + } + } + + async fn mark_uid_7_seen(mk: &Agentmail) -> Result { + mk.update_flags( + "INBOX", + "work", + 7, + 9, + &["\\Seen".to_string()], + &[], + FlagColorChange::Leave, + ) + .await + } + + /// The read-back after the STOREs is the result. A connection that drops + /// before it arrives is that, not "message not found" — the message is + /// still there, and repeating the idempotent update is the remedy. + #[tokio::test] + async fn a_flag_read_back_on_a_closed_connection_is_a_connection_error() { + let (mk, server) = agentmail_on(flag_account(None)).await; + + let error = mark_uid_7_seen(&mk) + .await + .expect_err("nothing was read back"); + + assert!(error.is_connection_error(), "{error:?}"); + drop(mk); + server.commands().await; + } + + /// Another client's change to UID 12 arrives as an unsolicited FETCH + /// ahead of the answer; the result is UID 7's flags, not UID 12's. + #[tokio::test] + async fn a_flag_read_back_answers_for_the_message_that_was_changed() { + let (mk, server) = agentmail_on(flag_account(Some( + "* 3 FETCH (UID 12 FLAGS (\\Flagged))\r\n* 1 FETCH (UID 7 FLAGS (\\Seen))\r\n", + ))) + .await; + + let updated = mark_uid_7_seen(&mk).await.expect("updated"); + + assert_eq!(updated.flags, vec!["\\Seen".to_string()]); + drop(mk); + server.commands().await; + } + + /// A sample FETCH on a closed stream returned no rows, so every sample + /// was reported deleted and pruned from the ranking cache — the "an + /// outage must not masquerade as deletion" rule, broken by a failure that + /// does not look like one. + #[tokio::test] + async fn a_closed_connection_prunes_no_ranking_samples() { + let (mut session, server) = imap_client::test_support::scripted_session(|tag, command| { + command + .contains("EXAMINE") + .then(|| imap_client::test_support::examine_reply(tag, 9, 100, 3)) + }) + .await; + let samples = [5, 7].map(|uid| MailboxMessageIdentity { + mailbox: "INBOX".to_string(), + uid_validity: 9, + uid, + }); + + let SampleSubjects { missing, .. } = sample_subjects(&mut session, &samples, None).await; + + assert!(missing.is_empty(), "{missing:?}"); + drop(session); + server.commands().await; + } } From 47a9f12e8f5ef54e65c13d3ac9a8ca740c599c7e Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:49:04 -0700 Subject: [PATCH 06/25] build: mail-auth on aws-lc-rs, not ring Agent Muse links this crate and is consolidating on one rustls crypto provider, aws-lc-rs: ring has no post-quantum key exchange, and two providers mean rustls can't pick one on its own. mail-auth's `aws-lc-rs` feature also selects hickory's TLS and DNSSEC backends, so ring leaves the build entirely. Lockfile: untrusted 0.7.1 added (aws-lc-rs), nothing else moved. --- Cargo.lock | 19 +++++++++++++------ Cargo.toml | 4 +++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 065f359..471f1e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -304,6 +304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -1380,6 +1381,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c480823ed7c2c5d0f09c41020cb6b7c28029ce60ec42dc942158dcf22f8e0a4d" dependencies = [ "async-trait", + "aws-lc-rs", "bitflags", "bytes", "cfg-if", @@ -1394,7 +1396,6 @@ dependencies = [ "lru-cache", "parking_lot", "rand 0.10.3", - "ring", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -1413,6 +1414,7 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d" dependencies = [ + "aws-lc-rs", "bitflags", "data-encoding", "idna", @@ -2032,6 +2034,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505122ba86e1f4adeb664196c1e787c3f29bb6e7c128e4a366d47d209911440" dependencies = [ + "aws-lc-rs", "flate2", "getrandom 0.2.17", "getrandom 0.4.3", @@ -2041,7 +2044,6 @@ dependencies = [ "mail-builder", "mail-parser", "memchr", - "ring", "rustls-pki-types", "serde", "serde_json", @@ -3011,7 +3013,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -3111,7 +3113,6 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -3176,7 +3177,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -3617,7 +3618,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3989,6 +3990,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index d5c4084..23534fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,9 @@ hashbrown = { version = "0.17", features = ["rayon", "serde"] } idna = "1" lettre = { version = "0.11", default-features = false, features = ["builder"] } mail-parser = "0.11" -mail-auth = { version = "0.13.3", default-features = false, features = ["ring"] } +# aws-lc-rs, not ring: Agent Muse links this crate and ships one rustls crypto +# provider. The feature also picks hickory's TLS and DNSSEC backends. +mail-auth = { version = "0.13.3", default-features = false, features = ["aws-lc-rs"] } native-tls = "0.2" parking_lot = "0.12" percent-encoding = "2" From d2e762555ae068d1996c7b6ed0a62cc0def4362c Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:37:39 -0700 Subject: [PATCH 07/25] updated dependencies --- Cargo.lock | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 471f1e6..eeca3a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2663,9 +2663,9 @@ dependencies = [ [[package]] name = "psl" -version = "2.1.239" +version = "2.1.241" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5736f71353f867ae262bd123b86ddb161f50a2db0300ab762c8f4a0e659635d3" +checksum = "c7319b480e204d67e759cad7886178335d26324c7c8573c126cc9dfa7350d064" dependencies = [ "psl-types", ] @@ -3358,9 +3358,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -3618,7 +3618,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3758,9 +3758,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.19" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ "bytes", "futures-core", @@ -3772,9 +3772,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.6+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap", "serde_core", @@ -3787,27 +3787,27 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tower" @@ -4418,18 +4418,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.60" +version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6400fc4bb7426f6faac3fb07566122bb18622e3022bd2c36426a92ed489b1dbe" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.60" +version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99c27cc7525bad4df59a45e073c45b2c2aa820efb588234cc23a547ec69a34db" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" dependencies = [ "proc-macro2", "quote", From f0aab98da2c876e89f0f5227be57db11e9cf49ff Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:04:35 -0700 Subject: [PATCH 08/25] build: IMAP's TLS on rustls with the OS checking certificates; Rust 1.99 native-tls meant three TLS stacks for one connection: Secure Transport on macOS (deprecated since 10.15, and it never offered TLS 1.3, so IMAP topped out at 1.2), SChannel on Windows, OpenSSL on Linux, where it also needed libssl-dev to build. Agent Muse links this crate and ships one rustls provider, aws-lc-rs. IMAP now connects through tokio-rustls on aws-lc-rs (its `rustls` re-export is the one used, so the two can't drift), and rustls-platform-verifier hands the certificate check to the OS: Security.framework, CryptoAPI, or the system roots on Linux. Roots a person or their MDM installed still count. The provider is named, not taken from the process default, so agentmail behaves the same whether or not its host installed one. The configuration is built once per process (tls_client_config, now public so examples/probe_xoauth2.rs probes the same connection). What a server loses: TLS 1.2 suites without forward secrecy or in CBC mode; native-tls already refused anything below 1.2. What a host loses: AgentmailError::Tls now carries the handshake's std::io::Error (tokio- rustls's report, rustls's error inside) and is no longer a From conversion, which is breaking. It still counts as a connection error, so a failed handshake is retried once on a fresh connection as before. Tests: the configuration builds with the platform verifier, and an ignored live test completes a verified handshake with imap.gmail.com:993 and reads the IMAP greeting. Run on macOS, it negotiated TLS 1.3. rust-version moves to 1.99, the toolchain Agent Muse builds with. Co-Authored-By: Claude --- CHANGELOG.md | 14 ++++++- Cargo.lock | 83 +-------------------------------------- Cargo.toml | 11 ++++-- README.md | 2 +- examples/probe_xoauth2.rs | 6 ++- src/error.rs | 6 ++- src/imap_client.rs | 80 ++++++++++++++++++++++++++++++++++--- 7 files changed, 107 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 789b1fa..8d4eceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ --- created: 2026-05-29T19:20 -updated: 2026-10-06T00:00 +updated: 2026-10-10T00:00 --- # Changelog @@ -48,6 +48,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **IMAP's TLS is rustls, with the OS checking certificates.** `native-tls` + and `tokio-native-tls` are gone; connections use `tokio-rustls` on aws-lc-rs, + and `rustls-platform-verifier` hands the certificate check to the OS + (Security.framework, CryptoAPI, the system store on Linux), so roots a person + or their MDM installed still count. On macOS IMAP now negotiates TLS 1.3: + native-tls ran Secure Transport there, which never offered it. A server whose + TLS 1.2 offers only non-forward-secret (RSA key exchange) or CBC suites no + longer connects; Linux no longer links OpenSSL. **Breaking:** + `AgentmailError::Tls` carries the handshake's `std::io::Error` instead of a + `native_tls::Error`, and is no longer a `From` conversion. + `imap_client::tls_client_config()` is public. +- **Rust 1.99 or newer** (`rust-version`). - **Replies are From the address the original reached** — the original's own `From` for a follow-up to this account's mail, else the first identity in its `To`, then `Cc`. `update_draft` keeps the draft's current `From` (name diff --git a/Cargo.lock b/Cargo.lock index eeca3a1..ea2c39f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,7 +49,6 @@ dependencies = [ "lettre", "mail-auth", "mail-parser", - "native-tls", "parking_lot", "percent-encoding", "printpdf", @@ -60,13 +59,14 @@ dependencies = [ "reqwest", "rmcp", "rusqlite", + "rustls-platform-verifier", "schemars", "serde", "serde_json", "sha2 0.11.0", "thiserror 2.0.21", "tokio", - "tokio-native-tls", + "tokio-rustls", "tokio-util", "toml", "tracing", @@ -1125,21 +1125,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -2145,23 +2130,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "ndk-context" version = "0.1.1" @@ -2286,49 +2254,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "openssl" -version = "0.10.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -3736,16 +3667,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.6" diff --git a/Cargo.toml b/Cargo.toml index 23534fe..ba39ffa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ imap-proto = { path = "vendor/imap-proto" } name = "agentmail" version = "0.7.0" edition = "2024" -rust-version = "1.94" +rust-version = "1.99" description = "IMAP email client, MCP server, and CLI — read, search, delete, unsubscribe, and manage mailboxes" documentation = "https://docs.rs/agentmail" readme = "README.md" @@ -50,7 +50,6 @@ mail-parser = "0.11" # aws-lc-rs, not ring: Agent Muse links this crate and ships one rustls crypto # provider. The feature also picks hickory's TLS and DNSSEC backends. mail-auth = { version = "0.13.3", default-features = false, features = ["aws-lc-rs"] } -native-tls = "0.2" parking_lot = "0.12" percent-encoding = "2" printpdf = { version = "0.12", default-features = false } @@ -72,7 +71,13 @@ serde_json = "1" sha2 = "0.11" thiserror = "2" tokio = { version = "1", features = ["full"] } -tokio-native-tls = "0.3" +# IMAP's TLS. rustls on aws-lc-rs, the one crypto provider Agent Muse ships +# (`tokio_rustls::rustls` is the rustls this uses, so the two can't drift), +# with the certificate check handed to the OS by rustls-platform-verifier, +# so the roots a person or their MDM installed still count. native-tls ran +# Secure Transport on macOS, which never gained TLS 1.3, and OpenSSL on Linux. +rustls-platform-verifier = "0.7" +tokio-rustls = { version = "0.26", default-features = false, features = ["aws-lc-rs", "tls12", "logging"] } tokio-util = "0.7" toml = "1.1" tracing = { version = "0.1", features = ["release_max_level_info"] } diff --git a/README.md b/README.md index dabd4c6..c67c3ce 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ during protocol design and review. ## Requirements -- Rust 1.94 or newer (edition 2024) +- Rust 1.99 or newer (edition 2024) - An IMAP-enabled email account on a server that advertises IMAP4rev1 (Gmail, iCloud, Yahoo, Fastmail, self-hosted, etc.). Dual rev1/rev2 servers are used in rev1 mode; pure IMAP4rev2 support is not yet available. ## Build diff --git a/examples/probe_xoauth2.rs b/examples/probe_xoauth2.rs index 80d4ef3..d5cb624 100644 --- a/examples/probe_xoauth2.rs +++ b/examples/probe_xoauth2.rs @@ -68,8 +68,10 @@ async fn main() -> Result<(), Box> { ); let tcp = TcpStream::connect((host.as_str(), port)).await?; - let connector = tokio_native_tls::TlsConnector::from(native_tls::TlsConnector::new()?); - let tls = connector.connect(&host, tcp).await?; + // The crate's own TLS configuration, so this probes the connection it makes. + let connector = tokio_rustls::TlsConnector::from(agentmail::imap_client::tls_client_config()?); + let server_name = tokio_rustls::rustls::pki_types::ServerName::try_from(host.clone())?; + let tls = connector.connect(server_name, tcp).await?; let mut client = async_imap::Client::new(tls); // Consume the greeting first (the fix): the AUTHENTICATE handshake does not diff --git a/src/error.rs b/src/error.rs index 9559a3e..29cb674 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,8 +5,12 @@ pub enum AgentmailError { #[error("IMAP error: {0}")] Imap(#[from] async_imap::error::Error), + /// The IMAP connection's TLS: building the client configuration, or the + /// handshake. tokio-rustls reports a failed handshake, a refused + /// certificate included, as an I/O error carrying rustls's own, so this + /// takes one and is never converted from `?` (that's [`Self::Io`]). #[error("TLS error: {0}")] - Tls(#[from] native_tls::Error), + Tls(std::io::Error), #[error("I/O error: {0}")] Io(#[from] std::io::Error), diff --git a/src/imap_client.rs b/src/imap_client.rs index 9466f5f..8c18049 100644 --- a/src/imap_client.rs +++ b/src/imap_client.rs @@ -9,7 +9,8 @@ use async_imap::extensions::compress::DeflateStream; use futures::StreamExt; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt as _}; use tokio::net::TcpStream; -use tokio_native_tls::TlsStream; +use tokio_rustls::client::TlsStream; +use tokio_rustls::rustls; use tracing::{debug, warn}; use crate::AgentmailError; @@ -744,6 +745,32 @@ pub async fn connect(config: &AccountConfig, password: &str) -> Result Result> { + use rustls_platform_verifier::BuilderVerifierExt as _; + + static CONFIG: std::sync::OnceLock> = std::sync::OnceLock::new(); + if let Some(config) = CONFIG.get() { + return Ok(Arc::clone(config)); + } + // The provider is named rather than taken from the process default, so + // agentmail works the same whether or not its host installed one. + let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); + let config = rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .and_then(|builder| builder.with_platform_verifier()) + .map_err(|e| AgentmailError::Tls(std::io::Error::other(e)))? + .with_no_client_auth(); + Ok(Arc::clone(CONFIG.get_or_init(|| Arc::new(config)))) +} + /// A single TLS connect + login, no retry. async fn connect_once(config: &AccountConfig, password: &str) -> Result { if !config.tls { @@ -751,13 +778,23 @@ async fn connect_once(config: &AccountConfig, password: &str) -> Result, serve_fetches: bool, From ace2c5907c19a3beda4f47e91da6874d55bab22a Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:43:59 -0700 Subject: [PATCH 09/25] build: Linux's keyring over zbus, so nothing links libdbus dbus-secret-service-keyring-store reached Secret Service through the C libdbus (libdbus-sys), the last system library agentmail linked on Linux after the move to rustls: every Linux build needed libdbus-1-dev and pkg-config, in this repo's CI, release, security and nightly-stress workflows and in Agent Muse's Linux CI leg. zbus-secret-service-keyring-store is the same store over zbus, a D-Bus implementation in Rust: the same Store::new() -> Result>, and identical code for the item's search attributes (service, username, target), so a password saved by the old store is found by the new one. Features rt-async-io-crypto-rust: zbus runs its own async-io executor, so the store's blocking calls work from any thread, a tokio worker included, rather than needing a tokio context (rt-tokio); crypto stays pure Rust. On Linux the graph now has no libdbus-sys and no openssl-sys; what -sys crates remain build from bundled sources (aws-lc-sys, whose build script only optionally asks pkg-config, and libsqlite3-sys). The workflows stop installing libdbus-1-dev and pkg-config: the reusable CI and release workflows install their *-linux-packages only when given, and security and nightly-stress drop their apt step. Checked on macOS: fmt, clippy (all targets, -D warnings) and the tests. The Linux arm of main.rs is not compiled here (no Linux C toolchain on this machine); its one change is the store's crate name, whose constructor and trait impl match. Co-Authored-By: Claude --- .github/workflows/ci.yml | 1 - .github/workflows/nightly-stress.yml | 3 - .github/workflows/release.yml | 3 - .github/workflows/security.yml | 3 - CHANGELOG.md | 7 + Cargo.lock | 614 ++++++++++++++++++--------- Cargo.toml | 2 +- src/main.rs | 2 +- 8 files changed, 431 insertions(+), 204 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86f3ca9..e276e0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,6 @@ jobs: check-os-json: '["ubuntu-latest","macos-latest","windows-latest"]' rust-cache-provider: github docs-os: ubuntu-latest - linux-packages: "libdbus-1-dev pkg-config" build-matrix-json: "[]" # Run nextest when available (preferred runner + .config/nextest.toml). # Falls back to `cargo test` if nextest is unavailable or on Windows. diff --git a/.github/workflows/nightly-stress.yml b/.github/workflows/nightly-stress.yml index 2cac57d..c6f5d59 100644 --- a/.github/workflows/nightly-stress.yml +++ b/.github/workflows/nightly-stress.yml @@ -25,9 +25,6 @@ jobs: with: toolchain: stable - - name: Install native build dependencies - run: sudo apt-get update && sudo apt-get install --yes libdbus-1-dev pkg-config - - name: Run 216K-message cache stress test run: >- cargo test --locked --all-features --lib diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e67521f..06858fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,9 +11,6 @@ jobs: with: validate-runs-on: ubuntu-latest rust-cache-provider: github - build-linux-packages: "libdbus-1-dev pkg-config" - validate-linux-packages: "libdbus-1-dev pkg-config" - publish-linux-packages: "libdbus-1-dev pkg-config" # binary-name: agentmail binary-matrix-json: >- [ diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 5082815..6c17f39 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -25,9 +25,6 @@ jobs: with: toolchain: stable - - name: Install native build dependencies - run: sudo apt-get update && sudo apt-get install --yes libdbus-1-dev pkg-config - - name: Install cargo-audit run: cargo install cargo-audit --locked diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d4eceb..dd30391 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `native_tls::Error`, and is no longer a `From` conversion. `imap_client::tls_client_config()` is public. - **Rust 1.99 or newer** (`rust-version`). +- **Linux's credential store talks D-Bus in Rust.** `zbus-secret-service-keyring-store` + (zbus, its own async-io executor, pure-Rust crypto) replaces + `dbus-secret-service-keyring-store`, which linked the C libdbus. It is the + same store over a different transport: the same Secret Service attributes + (`service`, `username`, `target`), so passwords saved before are found. + Building on Linux no longer needs `libdbus-1-dev` or `pkg-config`; CI, + release, security and nightly-stress no longer install them. - **Replies are From the address the original reached** — the original's own `From` for a follow-up to this account's mail, else the first identity in its `To`, then `Cc`. `update_draft` keeps the draft's current `From` (name diff --git a/Cargo.lock b/Cargo.lock index ea2c39f..30031b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,26 +8,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher 0.4.4", - "cpufeatures 0.2.17", -] - [[package]] name = "aes" version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ - "cipher 0.5.2", + "cipher", "cpubits", - "cpufeatures 0.3.1", + "cpufeatures", ] [[package]] @@ -39,7 +28,6 @@ dependencies = [ "base64 0.23.1", "chrono", "clap", - "dbus-secret-service-keyring-store", "dirs", "fast_html2md", "futures", @@ -63,7 +51,7 @@ dependencies = [ "schemars", "serde", "serde_json", - "sha2 0.11.0", + "sha2", "thiserror 2.0.21", "tokio", "tokio-rustls", @@ -73,6 +61,7 @@ dependencies = [ "tracing-subscriber", "uuid", "windows-native-keyring-store", + "zbus-secret-service-keyring-store", ] [[package]] @@ -204,6 +193,18 @@ dependencies = [ "security-framework", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener 5.4.2", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-channel" version = "1.9.0" @@ -239,6 +240,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + [[package]] name = "async-imap" version = "0.12.0" @@ -262,6 +277,88 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.2", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.2", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.92" @@ -354,15 +451,6 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "block-buffer" version = "0.12.1" @@ -374,20 +462,24 @@ dependencies = [ [[package]] name = "block-padding" -version = "0.3.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] -name = "block-padding" -version = "0.4.2" +name = "blocking" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ - "hybrid-array", + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", ] [[package]] @@ -428,22 +520,13 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher 0.4.4", -] - [[package]] name = "cbc" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" dependencies = [ - "cipher 0.5.2", + "cipher", ] [[package]] @@ -477,7 +560,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.1", + "cpufeatures", "rand_core 0.10.1", ] @@ -506,24 +589,14 @@ dependencies = [ "windows-link", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout 0.1.4", -] - [[package]] name = "cipher" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "crypto-common 0.2.2", - "inout 0.2.2", + "crypto-common", + "inout", ] [[package]] @@ -575,6 +648,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -660,15 +739,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "cpufeatures" version = "0.3.1" @@ -727,16 +797,6 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "crypto-common" version = "0.2.2" @@ -769,6 +829,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.23.0" @@ -809,45 +878,6 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" -[[package]] -name = "dbus" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" -dependencies = [ - "libc", - "libdbus-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "dbus-secret-service" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" -dependencies = [ - "aes 0.8.4", - "block-padding 0.3.3", - "cbc 0.1.2", - "dbus", - "fastrand", - "hkdf", - "num", - "once_cell", - "sha2 0.10.9", - "zeroize", -] - -[[package]] -name = "dbus-secret-service-keyring-store" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7227ffd3b4896266bbc100348025cebe51d9aea94ce118a1d68f4a6463963dae" -dependencies = [ - "dbus-secret-service", - "keyring-core", -] - [[package]] name = "deranged" version = "0.5.8" @@ -875,26 +905,16 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", - "subtle", -] - [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.1", + "block-buffer", "const-oid", - "crypto-common 0.2.2", + "crypto-common", + "ctutils", ] [[package]] @@ -962,7 +982,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac" dependencies = [ - "cipher 0.5.2", + "cipher", ] [[package]] @@ -1001,6 +1021,12 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enumflags2" version = "0.7.12" @@ -1008,6 +1034,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ "enumflags2_derive", + "serde", ] [[package]] @@ -1188,6 +1215,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1228,16 +1268,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "gethostname" version = "1.1.0" @@ -1359,6 +1389,18 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hickory-net" version = "0.26.3" @@ -1447,20 +1489,20 @@ dependencies = [ [[package]] name = "hkdf" -version = "0.12.4" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ "hmac", ] [[package]] name = "hmac" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.10.7", + "digest", ] [[package]] @@ -1730,23 +1772,13 @@ dependencies = [ "hashbrown 0.17.1", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "block-padding 0.3.3", - "generic-array", -] - [[package]] name = "inout" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ - "block-padding 0.4.2", + "block-padding", "hybrid-array", ] @@ -1891,15 +1923,6 @@ version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" -[[package]] -name = "libdbus-sys" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" -dependencies = [ - "pkg-config", -] - [[package]] name = "libredox" version = "0.1.25" @@ -1978,9 +2001,9 @@ version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e2ec995d822e05cabc3f06d196ee43650af3fe4fe38012cacb35e0c3d113b68" dependencies = [ - "aes 0.9.3", + "aes", "bitflags", - "cbc 0.2.1", + "cbc", "ecb", "encoding_rs", "flate2", @@ -1992,7 +2015,7 @@ dependencies = [ "nom", "rand 0.10.3", "rangemap", - "sha2 0.11.0", + "sha2", "stringprep", "thiserror 2.0.21", "weezl", @@ -2071,7 +2094,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest 0.11.3", + "digest", ] [[package]] @@ -2080,6 +2103,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -2266,6 +2298,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "ouroboros" version = "0.18.5" @@ -2477,12 +2519,37 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2551,6 +2618,15 @@ dependencies = [ "weezl", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -3179,6 +3255,25 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" +dependencies = [ + "aes", + "cbc", + "futures-util", + "getrandom 0.4.3", + "hkdf", + "hybrid-array", + "num", + "once_cell", + "serde", + "sha2", + "zbus", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3287,6 +3382,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "serde_spanned" version = "1.1.2" @@ -3305,17 +3411,6 @@ dependencies = [ "stable_deref_trait", ] -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - [[package]] name = "sha2" version = "0.11.0" @@ -3323,8 +3418,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", + "cpufeatures", + "digest", ] [[package]] @@ -3715,6 +3810,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + [[package]] name = "toml_parser" version = "1.1.5+spec-1.1.0" @@ -3854,6 +3961,17 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unarray" version = "0.1.4" @@ -3955,6 +4073,7 @@ checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" dependencies = [ "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -4295,6 +4414,9 @@ name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] [[package]] name = "wit-bindgen" @@ -4337,6 +4459,87 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener 5.4.2", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.62" @@ -4383,20 +4586,6 @@ name = "zeroize" version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] [[package]] name = "zerotrie" @@ -4442,3 +4631,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index ba39ffa..7e7e604 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,7 +92,7 @@ apple-native-keyring-store = { version = "1.0", features = ["keychain", "protect windows-native-keyring-store = "1.1" [target.'cfg(target_os = "linux")'.dependencies] -dbus-secret-service-keyring-store = { version = "1.0", features = ["crypto-rust"] } +zbus-secret-service-keyring-store = { version = "1.0", features = ["rt-async-io-crypto-rust"] } [lints.clippy] await_holding_lock = "deny" diff --git a/src/main.rs b/src/main.rs index 5e74587..a5d1c09 100644 --- a/src/main.rs +++ b/src/main.rs @@ -553,7 +553,7 @@ fn init_platform_keyring() { #[cfg(target_os = "linux")] { - match dbus_secret_service_keyring_store::Store::new() { + match zbus_secret_service_keyring_store::Store::new() { Ok(store) => keyring_core::set_default_store(store), Err(e) => tracing::warn!( "keychain: D-Bus secret service unavailable: {e}. \ From a3d11eea79df3866d6ea6e5a7df763ce85dc00e7 Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:48:47 -0700 Subject: [PATCH 10/25] build(deps): aws-lc-rs 1.18.2 / aws-lc-sys 0.46.0 aws-lc-sys before 0.46 emits bindgen's `#[link_name = "\u{1}aws_lc_..."]`. The `\u{1}` is an LLVM escape that cranelift takes literally, so a cranelift build fails to link with doubled-underscore symbols (`__aws_lc_0_45_0_SHA256`). aws/aws-lc-rs#1231, fixed by #1234 in 0.46.0. Agent Muse's own lockfile is already on these versions. This one matters when the agentmail binary is built by itself, as app-api's reload tests do under the host repo's NIGHTLY_CRANELIFT=1. Lock-only: no other package moved, and the suite passes on LLVM (494 tests). --- Cargo.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 30031b9..109b6d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -396,9 +396,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.18.1" +version = "1.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -407,9 +407,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" dependencies = [ "cc", "cmake", @@ -3644,7 +3644,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", From 6f5014f5d502364cf9be07d7d51e7806eb8daf51 Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:43:31 -0700 Subject: [PATCH 11/25] updated agentmail --- Cargo.lock | 246 ++++++++++++++++++++--------------------------------- 1 file changed, 92 insertions(+), 154 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 109b6d9..b2e322d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -332,7 +332,7 @@ checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -367,7 +367,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -432,18 +432,22 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "bit-set" -version = "0.8.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" dependencies = [ "bit-vec", ] [[package]] name = "bit-vec" -version = "0.8.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] [[package]] name = "bitflags" @@ -482,6 +486,30 @@ dependencies = [ "piper", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "brotli-decompressor" version = "5.0.3" @@ -531,9 +559,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.6.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ "find-msvc-tools", "jobserver", @@ -630,7 +658,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -880,9 +908,9 @@ checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" [[package]] name = "derive_more" @@ -946,7 +974,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1050,9 +1078,9 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -1236,7 +1264,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1291,18 +1319,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -1312,7 +1328,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 6.0.0", + "r-efi", "rand_core 0.10.1", "wasm-bindgen", ] @@ -1888,9 +1904,9 @@ dependencies = [ [[package]] name = "keyring-core" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +checksum = "29fbf4b551298c67925f38cf341a1ada797a75f1837bdf8ae1fc8e2ebc6efb59" dependencies = [ "log", ] @@ -2571,15 +2587,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2651,16 +2658,17 @@ dependencies = [ [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ "bit-set", "bit-vec", "bitflags", + "chacha20", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha", + "rand 0.10.3", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -2779,12 +2787,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -2800,16 +2802,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.3" @@ -2821,31 +2813,12 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_core" version = "0.10.1" @@ -2863,11 +2836,11 @@ dependencies = [ [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -2932,7 +2905,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3113,9 +3086,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", @@ -3128,9 +3101,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -3225,9 +3198,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" dependencies = [ "chrono", "dyn-clone", @@ -3239,14 +3212,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3355,7 +3328,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3366,14 +3339,14 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -3390,7 +3363,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3413,9 +3386,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures", @@ -3492,9 +3465,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" dependencies = [ "serde", ] @@ -3581,9 +3554,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -3607,7 +3580,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3644,7 +3617,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3687,7 +3660,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3736,9 +3709,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.2" +version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", "libc", @@ -3759,7 +3732,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4004,9 +3977,9 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-joining-type" @@ -4067,9 +4040,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.27.0" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -4129,15 +4102,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -4181,7 +4145,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -4418,12 +4382,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "writeable" version = "0.6.4" @@ -4455,7 +4413,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -4514,7 +4472,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zbus_names", "zvariant", "zvariant_utils", @@ -4540,26 +4498,6 @@ dependencies = [ "serde", ] -[[package]] -name = "zerocopy" -version = "0.8.62" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.62" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "zerofrom" version = "0.1.8" @@ -4577,7 +4515,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -4617,7 +4555,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4656,7 +4594,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zvariant_utils", ] @@ -4669,6 +4607,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.6", + "syn 3.0.9", "winnow", ] From 7650fb31aa9dd3c16610dfdfff421faba41a18d0 Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:33:51 -0700 Subject: [PATCH 12/25] updated sqlite --- Cargo.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b2e322d..d85fa0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -690,9 +690,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.8" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "memchr", @@ -1790,9 +1790,9 @@ dependencies = [ [[package]] name = "inout" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +checksum = "d8c0d789294fb457b791f7797a04478704788f30b407e5d0b01deaa846572d9f" dependencies = [ "block-padding", "hybrid-array", @@ -3189,9 +3189,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" dependencies = [ "windows-sys 0.61.2", ] @@ -4480,9 +4480,9 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.4" +version = "4.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" dependencies = [ "serde", "winnow", @@ -4572,9 +4572,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zvariant" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" dependencies = [ "endi", "enumflags2", @@ -4587,9 +4587,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" dependencies = [ "proc-macro-crate", "proc-macro2", From 14d0415be4e6577b80af325efe8a47578a1e4e5a Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:37:09 -0700 Subject: [PATCH 13/25] update agent mail --- Cargo.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d85fa0e..8841d4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -230,9 +230,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.50" +version = "0.4.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" +checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970" dependencies = [ "compression-codecs", "compression-core", @@ -700,9 +700,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" +checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe" dependencies = [ "compression-core", "flate2", @@ -710,9 +710,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" +checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a" [[package]] name = "concurrent-queue" @@ -1790,9 +1790,9 @@ dependencies = [ [[package]] name = "inout" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8c0d789294fb457b791f7797a04478704788f30b407e5d0b01deaa846572d9f" +checksum = "3ce983bb6fb399067d101fbfc9e74174e07e9f29a2bc679efefeb8c645819c3f" dependencies = [ "block-padding", "hybrid-array", @@ -1935,9 +1935,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.190" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libredox" @@ -3989,9 +3989,9 @@ checksum = "d8d00a78170970967fdb83f9d49b92f959ab2bb829186b113e4f4604ad98e180" [[package]] name = "unicode-normalization" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" dependencies = [ "tinyvec", ] From 4ef0cd9a4191a49c764ffce3f772172ad9cde829 Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:29:02 -0700 Subject: [PATCH 14/25] cargo update --- Cargo.lock | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8841d4b..13599a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -559,9 +559,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" dependencies = [ "find-msvc-tools", "jobserver", @@ -778,9 +778,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -1153,9 +1153,9 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "flate2" @@ -1790,9 +1790,9 @@ dependencies = [ [[package]] name = "inout" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ce983bb6fb399067d101fbfc9e74174e07e9f29a2bc679efefeb8c645819c3f" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" dependencies = [ "block-padding", "hybrid-array", @@ -2678,9 +2678,9 @@ dependencies = [ [[package]] name = "psl" -version = "2.1.241" +version = "2.1.243" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7319b480e204d67e759cad7886178335d26324c7c8573c126cc9dfa7350d064" +checksum = "6600966e937eeb6e279d7d5a35b9c61ca720f4a5ab6c950a3b921ca4881e0054" dependencies = [ "psl-types", ] @@ -3150,9 +3150,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -3709,9 +3709,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.3" +version = "1.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" dependencies = [ "bytes", "libc", @@ -4180,9 +4180,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] From 8bef3630ec2264832b0d0906f07d3ea1bd94beb7 Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:53:00 -0700 Subject: [PATCH 15/25] feat!: retire MCP tasks (SEP-1686); 0.8.0 The server no longer advertises capabilities.tasks, no tool declares execution.taskSupport (22 did), and tasks/list|get|result|cancel are gone, with the per-account serialization of destructive tasks, the notifications/tasks/status push and the related-task _meta. Agent Muse, which embeds this server, measured the task surface unused, and every rmcp 3.x release removes the MCP 2025-11-25 task model. A task-augmented tools/call is refused and the tool never runs; long operations still report progress and honour notifications/cancelled. Also: the fresh_logins / idle_reuses counters are labelled pool-wide (pool_fresh_logins / pool_idle_reuses in the log lines; ConnectionStats documents that they cover every account and reset with the pool). Breaking for a 0.x crate, hence 0.8.0. --- CHANGELOG.md | 24 +- Cargo.lock | 2 +- Cargo.toml | 9 +- DESIGN.md | 8 +- MCP.md | 75 ++- README.md | 20 +- src/connection.rs | 20 +- src/mcp/mod.rs | 218 +------- src/mcp/tasks.rs | 1077 -------------------------------------- src/mcp/tools_read.rs | 24 +- src/mcp/tools_write.rs | 42 +- tests/mcp_integration.rs | 232 ++------ 12 files changed, 154 insertions(+), 1597 deletions(-) delete mode 100644 src/mcp/tasks.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index dd30391..472ac36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ --- created: 2026-05-29T19:20 -updated: 2026-10-10T00:00 +updated: 2026-10-13T00:00 --- # Changelog @@ -106,6 +106,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 rusqlite stays at 0.39 (the embedding app's sqlx-sqlite caps libsqlite3-sys below 0.38) and rmcp at 2.2.0 (3.x removes the task API; its own decision in the embedding workspace). +- **`fresh_logins` / `idle_reuses` are labelled as pool-wide** — the log lines + that carry them now name them `pool_fresh_logins` and `pool_idle_reuses`, + and `ConnectionStats` documents that the counts cover every account since the + pool was created. Printed beside one `account`, they read as that account's + own counts; a server that is rebuilt (the embedding app does so when mail + accounts change) starts them from zero. + +### Removed + +- **MCP tasks (SEP-1686), version 0.8.0** — the server no longer advertises + `capabilities.tasks`, no tool declares `execution.taskSupport` (22 did), and + `tasks/list`, `tasks/get`, `tasks/result` and `tasks/cancel` are gone, with + the per-account serialization of destructive tasks and the + `notifications/tasks/status` push. Agent Muse, which embeds this server, + measured the task surface unused (no task-augmented call, no `tasks/*` + request in two weeks of traffic), and every rmcp 3.x release removes the + MCP 2025-11-25 task model. A task-augmented `tools/call` is refused and the + tool does not run. Long operations still report `notifications/progress` and + honour `notifications/cancelled`. Tasks may return as the SEP-2663 + extension. A breaking change, hence the minor version bump. +- **The `io.modelcontextprotocol/related-task` marker** on progress + notifications and task results went with them. ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 13599a3..e8aae3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agentmail" -version = "0.7.0" +version = "0.8.0" dependencies = [ "apple-native-keyring-store", "async-imap", diff --git a/Cargo.toml b/Cargo.toml index 7e7e604..8c1a674 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ imap-proto = { path = "vendor/imap-proto" } [package] name = "agentmail" -version = "0.7.0" +version = "0.8.0" edition = "2024" rust-version = "1.99" description = "IMAP email client, MCP server, and CLI — read, search, delete, unsubscribe, and manage mailboxes" @@ -55,9 +55,10 @@ percent-encoding = "2" printpdf = { version = "0.12", default-features = false } psl = "2" reqwest = { version = "0.13", features = ["json"] } -# Pinned to the bridge's rmcp. 3.x removes the task API this server's background -# tools are built on; the move is one decision across the Agent Muse bridge, -# agentmail and eventkit-rs, deferred to its own ADR in the embedding workspace. +# Pinned to the bridge's rmcp until the 3.x bump lands across the Agent Muse +# bridge, agentmail and eventkit-rs. The v1 task API that 3.x removes is gone +# from this server since 0.8.0; the protocol stays at MCP 2025-11-25 (the +# embedding workspace's bridge D41). rmcp = { version = "=2.2.0", features = ["server", "macros", "transport-io"] } # Held at 0.39 (libsqlite3-sys ^0.37) so embedding workspaces that also carry # sqlx-sqlite 0.9 (libsqlite3-sys >=0.30.1,<0.38) can converge on one native diff --git a/DESIGN.md b/DESIGN.md index 528aab8..25f2fee 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -20,7 +20,7 @@ graph TB end subgraph "agentmail-mcp (in-process)" - MCP[AgentMailServer
36 tools, 6 prompts, tasks] + MCP[AgentMailServer
36 tools, 6 prompts] MK[Agentmail Facade] POOL[ConnectionPool
provider-aware cap/account] CRED[Credential Resolver] @@ -82,7 +82,7 @@ agentmail/ src/ lib.rs # Agentmail facade (25+ async methods) main.rs # CLI dispatch (clap), account configuration - mcp/ # AgentMailServer, tools, prompts, tasks, resources, transports + mcp/ # AgentMailServer, tools, prompts, resources, transports imap_client.rs # Raw IMAP operations (SELECT, FETCH, SEARCH, STORE) connection.rs # Per-account session pool + semaphore concurrency config.rs # AccountConfig, Config (file + programmatic) @@ -266,9 +266,9 @@ Email content flows through a pipeline: - **Config file for standalone, runtime injection for in-process** — same library code, different config sources - **Passwords in OS keyring, never in DB** — proper security, no key management burden - **Mailbox attributes** — RFC 6154 special-use roles (trash, junk, drafts, sent, archive, all, flagged) and RFC 3501 flags (noSelect, noInferiors) surfaced from IMAP LIST. Scan-all operations use roles to skip Trash/Junk/Drafts with string-matching fallback for servers without RFC 6154 support -- **Tool annotations** — `read_only_hint`, `destructive_hint`, `idempotent_hint`, and optional task execution per MCP 2025-11-25 +- **Tool annotations** — `read_only_hint`, `destructive_hint`, `idempotent_hint` per MCP 2025-11-25 - **Progress notifications** — long operations report progress to the MCP client -- **Task support (SEP-1686)** — taskable operations support enqueue, poll, repeatable result retrieval, and cancellation. Destructive tasks targeting the same account are serialized to prevent IMAP state conflicts +- **No MCP tasks** — the SEP-1686 task surface (enqueue, poll, result, cancel, per-account serialization of destructive tasks) was retired in 0.8.0, measured unused; long operations report progress and honour `notifications/cancelled` - **Forward recovery for MOVE** — servers without native UID MOVE use a separate `synchronous=FULL` journal. Ambiguous COPY/cleanup outcomes return durable operation IDs and explicit pending/attention states; reconciliation never repeats COPY without evidence that the earlier attempt created nothing ## Configuration and Local Security diff --git a/MCP.md b/MCP.md index 81831cb..273d91d 100644 --- a/MCP.md +++ b/MCP.md @@ -6,7 +6,7 @@ updated: 2026-08-23T00:00 MCP protocol: [2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25) (also negotiates 2025-06-18, 2025-03-26, and 2024-11-05) | [rmcp](https://crates.io/crates/rmcp) (official Rust MCP SDK) | Transport: stdio (standalone) or AsyncRead+AsyncWrite (in-process) -**Supported capabilities:** tools, prompts, resources, completions, tasks (SEP-1686), progress notifications, request cancellation +**Supported capabilities:** tools, prompts, resources, completions, progress notifications, request cancellation **Contract conventions:** @@ -121,14 +121,14 @@ an unknown account raises `-32602`. | --- | ------------------ | --------------------------------------------------------------------------------------------------- | ---------------------- | | 6 | `get_messages` | Paginated metadata discovery from one required mailbox, newest-first. Default: offset=0, limit=25 (max 50) | `read_only` | | 7 | `search_messages` | Paginated IMAP metadata search of one required mailbox with text, headers, status, date, and size filters. | `read_only` | -| 8 | `list_flags` | All IMAP flags in use with counts. Resolves Apple $MailFlagBit colors. Omit mailbox to scan all. | `read_only`, `taskable` | -| 9 | `find_attachments` | Scan for messages with attachments (mixed + related), paginated. Omit mailbox to scan all. | `read_only`, `taskable` | -| 10 | `top_senders` | Top senders by volume (email, display name) with counts + date ranges. Omit mailbox to scan all. | `read_only`, `taskable` | -| 11 | `top_subscriptions` | Top bulk-mail senders with advertised one-click syntax and UIDVALIDITY-guarded samples. | `read_only`, `taskable` | -| 12 | `top_mailing_lists` | Top mailing lists by List-Id (RFC 2919). Groups across senders. Omit mailbox to scan all. | `read_only`, `taskable` | -| 13 | `top_domains` | Exact canonical Header From domains and subdomains with counts, dates, and a live sample subject. | `read_only`, `taskable` | +| 8 | `list_flags` | All IMAP flags in use with counts. Resolves Apple $MailFlagBit colors. Omit mailbox to scan all. | `read_only` | +| 9 | `find_attachments` | Scan for messages with attachments (mixed + related), paginated. Omit mailbox to scan all. | `read_only` | +| 10 | `top_senders` | Top senders by volume (email, display name) with counts + date ranges. Omit mailbox to scan all. | `read_only` | +| 11 | `top_subscriptions` | Top bulk-mail senders with advertised one-click syntax and UIDVALIDITY-guarded samples. | `read_only` | +| 12 | `top_mailing_lists` | Top mailing lists by List-Id (RFC 2919). Groups across senders. Omit mailbox to scan all. | `read_only` | +| 13 | `top_domains` | Exact canonical Header From domains and subdomains with counts, dates, and a live sample subject. | `read_only` | | 14 | `list_pending_moves` | List durable COPY-fallback MOVE operations awaiting reconciliation or review. | `read_only` | -| 15 | `preview_thread_record` | Discover a bounded exact Message-ID graph and return a confirmation digest without writing files. | `read_only`, `taskable` | +| 15 | `preview_thread_record` | Discover a bounded exact Message-ID graph and return a confirmation digest without writing files. | `read_only` | #### Output Schemas @@ -340,26 +340,26 @@ shown by the preview to a later `export_thread_record` confirmation. | # | Tool | Description | Annotations | | --- | ---------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------- | -| 16 | `delete_messages` | Delete by UID (up to 500). Moves to Trash, or permanently expunges when `permanent=true`. | `destructive`, `idempotent`, `taskable` | -| 17 | `delete_by_sender` | Delete all from an exact sender identity (`email` + `name` from a ranking row). Omit `mailbox` for account-wide. `permanent=true` bypasses Trash. | `destructive`, `taskable` | -| 18 | `delete_list_id` | Delete all messages with an **exact** List-Id across all mailboxes. `permanent=true` bypasses Trash. | `destructive`, `taskable` | -| 19 | `delete_by_domain` | Delete all messages from one exact canonical domain from `top_domains`; subdomains are never implicit. | `destructive`, `taskable` | -| 20 | `move_list_id` | Move all messages with an **exact** List-Id to a destination mailbox in one operation (e.g. archive a statement list). Omit `mailbox` for account-wide; destination excluded. | `taskable` | -| 21 | `move_by_sender` | Move all messages from an exact sender identity (`email` + `name`) to a destination mailbox in one operation. Omit `mailbox` for account-wide; destination excluded. | `taskable` | -| 22 | `move_by_domain` | Move all messages from one exact canonical domain to a destination; subdomains are never implicit. | `taskable` | -| 23 | `move_subscription` | Move the exact bulk-mail subscription represented by a UIDVALIDITY-safe `top_subscriptions` sample; destination excluded. | `taskable` | +| 16 | `delete_messages` | Delete by UID (up to 500). Moves to Trash, or permanently expunges when `permanent=true`. | `destructive`, `idempotent` | +| 17 | `delete_by_sender` | Delete all from an exact sender identity (`email` + `name` from a ranking row). Omit `mailbox` for account-wide. `permanent=true` bypasses Trash. | `destructive` | +| 18 | `delete_list_id` | Delete all messages with an **exact** List-Id across all mailboxes. `permanent=true` bypasses Trash. | `destructive` | +| 19 | `delete_by_domain` | Delete all messages from one exact canonical domain from `top_domains`; subdomains are never implicit. | `destructive` | +| 20 | `move_list_id` | Move all messages with an **exact** List-Id to a destination mailbox in one operation (e.g. archive a statement list). Omit `mailbox` for account-wide; destination excluded. | — | +| 21 | `move_by_sender` | Move all messages from an exact sender identity (`email` + `name`) to a destination mailbox in one operation. Omit `mailbox` for account-wide; destination excluded. | — | +| 22 | `move_by_domain` | Move all messages from one exact canonical domain to a destination; subdomains are never implicit. | — | +| 23 | `move_subscription` | Move the exact bulk-mail subscription represented by a UIDVALIDITY-safe `top_subscriptions` sample; destination excluded. | — | | 24 | `move_message` | IMAP MOVE between mailboxes (durable COPY+EXPUNGE fallback when MOVE is unavailable). | | -| 25 | `reconcile_moves` | Safely resume one or all pending COPY-fallback MOVE operations. | `destructive`, `taskable` | +| 25 | `reconcile_moves` | Safely resume one or all pending COPY-fallback MOVE operations. | `destructive` | | 26 | `create_mailbox` | Create new folder | `idempotent` | | 27 | `rename_mailbox` | Preview, then confirm a guarded mailbox rename. | `destructive` | | 28 | `delete_mailbox` | Preview, then confirm guarded mailbox deletion. | `destructive`, `idempotent` | | 29 | `create_draft` | Save an RFC822 draft — fresh, or a reply derived from a live message via `replyToMessage`. | | | 30 | `update_draft` | Replace a live draft: RFC 8508 REPLACE where the server has it, APPEND-then-discard otherwise. | `destructive` | -| 31 | `download_attachments` | Extract attachments to disk as `{uid}_{index}_{filename}` | `taskable` | -| 32 | `download_message_source` | Save exact RFC822 bytes directly to disk with SHA-256, metadata, and local DNS-backed DKIM evidence. | `open_world`, `taskable` | -| 33 | `download_thread` | Save a caller-selected set of up to 100 UIDs plus a JSON evidence manifest. Does not discover thread membership. | `open_world`, `taskable` | -| 34 | `export_thread_record` | Confirm a preview digest and write PDF, exact EML sources, and an integrity manifest. | `open_world`, `taskable` | -| 35 | `unsubscribe_message` | DKIM-verified RFC 8058 POST; optional matching-message cleanup via the nested `cleanup {when, identity, deletion}` object (omitted = unsubscribe only). | `destructive`, `open_world`, `taskable` | +| 31 | `download_attachments` | Extract attachments to disk as `{uid}_{index}_{filename}` | — | +| 32 | `download_message_source` | Save exact RFC822 bytes directly to disk with SHA-256, metadata, and local DNS-backed DKIM evidence. | `open_world` | +| 33 | `download_thread` | Save a caller-selected set of up to 100 UIDs plus a JSON evidence manifest. Does not discover thread membership. | `open_world` | +| 34 | `export_thread_record` | Confirm a preview digest and write PDF, exact EML sources, and an integrity manifest. | `open_world` | +| 35 | `unsubscribe_message` | DKIM-verified RFC 8058 POST; optional matching-message cleanup via the nested `cleanup {when, identity, deletion}` object (omitted = unsubscribe only). | `destructive`, `open_world` | **`permanent` flag (delete tools):** default false moves to Trash when a Trash mailbox exists, else permanently deletes. When true, flags `\Deleted` + UID EXPUNGE directly, bypassing Trash — irreversible. Permanent delete requires the server to advertise UIDPLUS; on servers without it the call is refused (plain EXPUNGE would purge unrelated `\Deleted` messages). @@ -748,8 +748,8 @@ belong to the same mailbox and UIDVALIDITY epoch; existing source or manifest filenames cause a no-overwrite failure. For embedded AgentMail, Agent Muse injects the trusted absolute workspace root -as request metadata (`io.agentmuse/workspaceRoot`) on both direct and -task-augmented calls. Only the in-process backend named `agentmail` receives +as request metadata (`io.agentmuse/workspaceRoot`) on every call. Only the +in-process backend named `agentmail` receives it; a missing or invalid root fails closed, and other backends never receive the value. Standalone `agentmail serve` uses `AGENTMAIL_FILE_ROOT` or its `~/.agentmail/files` default instead. @@ -875,23 +875,17 @@ Returns the full updated flag set after the operation. | 5 | `unsubscribe-cleanup` | Rank subscriptions; offer filing or verified unsubscribe | `account` | | 6 | `list-id-cleanup` | Identify mailing lists by List-Id, bulk-delete | `account` | -## Task Support (SEP-1686) +## Tasks (SEP-1686) — retired in 0.8.0 -The tools listed below support `execution.taskSupport = "optional"` — clients can invoke them normally (synchronous with progress notifications) or as background tasks (enqueue, poll, retrieve result). - -**Taskable tools:** `search_messages`, `list_flags`, `find_attachments`, `top_senders`, `top_domains`, `top_subscriptions`, `top_mailing_lists`, `preview_thread_record`, `delete_messages`, `delete_by_sender`, `delete_by_domain`, `delete_list_id`, `move_list_id`, `move_by_sender`, `move_by_domain`, `move_subscription`, `reconcile_moves`, `download_attachments`, `download_message_source`, `download_thread`, `export_thread_record`, `unsubscribe_message` - -**Destructive task serialization:** Destructive tasks (`delete_messages`, `delete_mailbox`, `delete_by_sender`, `delete_by_domain`, `delete_list_id`, `rename_mailbox`, `update_draft`, `reconcile_moves`, and `unsubscribe_message`) targeting the same account are serialized — each waits for the previous destructive task to finish before starting. The serialization list also protects those names if they become taskable later. Non-destructive tasks run concurrently without restriction. - -**Task lifecycle:** `tasks/list`, `tasks/get`, `tasks/result`, `tasks/cancel` - -**Cancellation:** `tasks/cancel` first cancels the cooperative task token and then aborts the async future; active SQLite publication checks that token and rolls back. For direct calls, `notifications/cancelled` stops scans at mailbox/fetch-chunk boundaries and interrupts unsubscribe DNS, DKIM, and HTTP waits through a 25 ms cancellation poll. Cancellation during an HTTP send is inherently ambiguous because the endpoint may already have received the POST. - -Tasks are retained for 24 hours from creation, including completed, failed, and -cancelled metadata. At most 128 live tasks/reservations are accepted per server -process. `tasks/list` is newest-first in pages of 25 and uses an opaque, -process-local cursor. `tasks/result` is repeatable until expiry; retrieving a -result does not evict it. Expired active tasks are cancelled and removed. +AgentMail no longer offers MCP tasks: no `tasks` capability, no +`execution.taskSupport` on any tool, no `tasks/*` methods. The task surface was +measured unused by the app that embeds this server, and every rmcp 3.x release +removes the MCP 2025-11-25 task model; tasks may return as the SEP-2663 +extension. Long operations report `notifications/progress`, and a direct call +is cancelled with `notifications/cancelled`: scans stop at mailbox/fetch-chunk +boundaries and unsubscribe DNS, DKIM and HTTP waits are interrupted through a +25 ms cancellation poll. Cancellation during an HTTP send is inherently +ambiguous because the endpoint may already have received the POST. ## Tool Result Encoding @@ -988,4 +982,3 @@ resources and displays them in the MCP Inspector. | `destructive` | Permanently deletes or modifies messages | | `idempotent` | Safe to call multiple times with same arguments | | `open_world` | Makes external HTTP requests (e.g. one-click unsubscribe POST) | -| `taskable` | Supports `execution.taskSupport = "optional"` (SEP-1686) | diff --git a/README.md b/README.md index c67c3ce..820011a 100644 --- a/README.md +++ b/README.md @@ -437,14 +437,14 @@ To pass passwords via environment variables instead of keychain: npx @modelcontextprotocol/inspector /path/to/agentmail serve ``` -Opens a web UI to exercise all advertised tools, 6 prompts, and task calls interactively. +Opens a web UI to exercise all advertised tools and 6 prompts interactively. ## MCP Tools 36 tools cover account discovery, mailbox management, message reading, search, -bulk operations, recovery, evidence archiving, flag management, and composition. AgentMail saves drafts but never sends mail. 22 long-running tools support -optional [task-based invocation](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/tasks) -(SEP-1686) for asynchronous execution. +bulk operations, recovery, evidence archiving, flag management, and composition. AgentMail saves drafts but never sends mail. Long-running tools +report progress notifications. (MCP task-based invocation, SEP-1686, was +retired in 0.8.0.) | Tool | Description | | ---------------------- | ------------------------------------------------------------------------------------- | @@ -521,8 +521,7 @@ optional [task-based invocation](https://modelcontextprotocol.io/specification/2 `senders` preview nested inside one `top_mailing_lists` row, whose `senderCount` still reports the complete count. - All reads use `BODY.PEEK` to avoid marking messages as `\Seen`. -- Tools marked taskable in [MCP.md](MCP.md) support MCP progress notifications - and optional task-based invocation. +- Long-running tools support MCP progress notifications. - Cancelling a request (`notifications/cancelled`) stops long scans at the next mailbox/fetch chunk and polls DNS, DKIM, and HTTP work every 25 ms. An HTTP cancellation cannot retract a POST already received by the remote server, so it never reports that an in-flight request was definitely unsent. - Delete tools take a `permanent` flag (default false): false moves to Trash when available, true expunges directly (bypassing Trash, irreversible; requires server UIDPLUS). - The `top_*` tools share a persistent, live-validated UID/header cache. Each invocation uses `EXAMINE` before reuse. An unchanged `UIDVALIDITY`/`UIDNEXT`/message-count tuple is a hit; a proven append fetches only new UIDs; deletions and mixed changes reconcile UID membership while reusing unchanged header rows. A changed or missing `UIDVALIDITY` prevents unsafe UID reuse. If discovery enumerates folders, results dedupe by Message-ID and exclude your own configured email and aliases. @@ -565,8 +564,8 @@ optional [task-based invocation](https://modelcontextprotocol.io/specification/2 client IP, HELO, and envelope-sender inputs that are not present in an RFC822 archive. - Embedded AgentMail accepts the workspace root only as trusted request - metadata from Agent Muse (`io.agentmuse/workspaceRoot`) on direct and - task-augmented calls. Missing or invalid metadata fails closed. Standalone + metadata from Agent Muse (`io.agentmuse/workspaceRoot`) on every call. + Missing or invalid metadata fails closed. Standalone `agentmail serve` instead uses `AGENTMAIL_FILE_ROOT` (falling back to `~/.agentmail/files`). Caller-supplied output directories are confined below that root; a process-wide environment variable is never used for embedded @@ -605,7 +604,6 @@ optional [task-based invocation](https://modelcontextprotocol.io/specification/2 - Tool calls return one compact text summary for compatibility plus one authoritative `structuredContent` object. The full JSON value is not repeated in the text content block. -- Destructive tasks targeting the same account are automatically serialized to prevent IMAP state conflicts. Tasks are capped at 128 live entries, retained for 24 hours from creation, listed newest-first in opaque-cursor pages of 25, and may have their terminal result retrieved repeatedly until expiry. ### Ranking cache privacy and configuration @@ -771,7 +769,7 @@ Argument autocompletion (`completion/complete`) is supported for the prompts and ``` agentmail (binary crate: agentmail-mcp) ├── serve → MCP stdio server (tokio + rmcp) - │ 36 tools + 6 prompts, tasks, progress notifications + │ 36 tools + 6 prompts, progress notifications ├── list-accounts → CLI ├── list-mailboxes → CLI ├── create-mailbox → CLI @@ -795,7 +793,7 @@ agentmail (binary crate: agentmail-mcp) src/ (library + binary) ├── lib.rs → Public API facade (25+ async methods) ├── main.rs → CLI dispatch (clap), account configuration - ├── mcp/ → MCP server: 36 tools, 6 prompts, tasks, resources, completions + ├── mcp/ → MCP server: 36 tools, 6 prompts, resources, completions ├── config.rs → TOML config loading, default account resolution ├── credentials.rs → Password resolution (env → config secret → default keyring) ├── connection.rs → IMAP connection pool (provider-aware per-account cap) diff --git a/src/connection.rs b/src/connection.rs index 756afab..f8f6319 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -109,6 +109,10 @@ pub struct ConnectionPool { /// acquire and keepalive tick regardless of log level (plain atomics, not /// `tracing` macros that compile out in release), so "are we actually holding /// the connections we open?" is answerable by reading numbers, not just logs. +/// +/// POOL-WIDE: one set per [`ConnectionPool`], summed over every account, and +/// gone with the pool — a server that is rebuilt (the app does so when mail +/// accounts change) starts again from zero. #[derive(Debug, Default)] struct PoolStats { fresh_logins: AtomicU64, @@ -118,7 +122,9 @@ struct PoolStats { } /// A snapshot of the pool's connection-lifecycle counters — the direct -/// evidence for whether connections are being held and reused. +/// evidence for whether connections are being held and reused. The counts are +/// POOL-WIDE (every account together, since this pool was created), not per +/// account; the log lines that carry them name them `pool_*` for that reason. /// /// `fresh_logins` counts accepted LOGINs (the expensive, rate-limited event); /// `idle_reuses` counts pooled-session hits that skipped a LOGIN entirely. In a @@ -744,8 +750,8 @@ impl ConnectionPool { let reuses = self.stats.idle_reuses.fetch_add(1, Ordering::Relaxed) + 1; tracing::debug!( account = %account_name, - idle_reuses = reuses, - fresh_logins = self.stats.fresh_logins.load(Ordering::Relaxed), + pool_idle_reuses = reuses, + pool_fresh_logins = self.stats.fresh_logins.load(Ordering::Relaxed), uid_mode, "reused a held IMAP session (no LOGIN)", ); @@ -782,8 +788,8 @@ impl ConnectionPool { let reuses = self.stats.idle_reuses.fetch_add(1, Ordering::Relaxed) + 1; tracing::debug!( account = %account_name, - idle_reuses = reuses, - fresh_logins = self.stats.fresh_logins.load(Ordering::Relaxed), + pool_idle_reuses = reuses, + pool_fresh_logins = self.stats.fresh_logins.load(Ordering::Relaxed), uid_mode, "reused a held IMAP session (no LOGIN)", ); @@ -818,8 +824,8 @@ impl ConnectionPool { let logins = self.stats.fresh_logins.fetch_add(1, Ordering::Relaxed) + 1; tracing::info!( account = %account_name, - fresh_logins = logins, - idle_reuses = self.stats.idle_reuses.load(Ordering::Relaxed), + pool_fresh_logins = logins, + pool_idle_reuses = self.stats.idle_reuses.load(Ordering::Relaxed), "opened a fresh IMAP connection (LOGIN); later ops should reuse it", ); session diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index ce96657..d2dd9c0 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -1,32 +1,28 @@ -//! MCP server: tools, prompts, and tasks over stdio or in-process transports. +//! MCP server: tools, prompts, resources and completions over stdio or +//! in-process transports. mod args; mod file_access; mod prompts; mod resources; -mod tasks; mod tools_read; mod tools_write; mod wire; -use self::tasks::{DESTRUCTIVE_TOOLS, TaskCompletion, TaskManager, extract_account}; -use futures::{FutureExt as _, StreamExt as _}; +use futures::StreamExt as _; use rmcp::{ ErrorData as McpError, Peer, RoleServer, ServerHandler, ServiceExt, handler::server::router::tool::ToolRouter, model::{ - CallToolRequestParams, CallToolResult, CancelTaskParams, CancelTaskResult, - CompleteRequestParams, CompleteResult, CreateTaskResult, GetTaskParams, - GetTaskPayloadParams, GetTaskPayloadResult, GetTaskResult, Implementation, - ListResourceTemplatesResult, ListResourcesResult, ListTasksResult, Meta, - PaginatedRequestParams, ProgressNotificationParam, ReadResourceRequestParams, - ReadResourceResult, RelatedTaskMetadata, ServerCapabilities, ServerInfo, TasksCapability, + CompleteRequestParams, CompleteResult, Implementation, ListResourceTemplatesResult, + ListResourcesResult, Meta, PaginatedRequestParams, ProgressNotificationParam, + ReadResourceRequestParams, ReadResourceResult, ServerCapabilities, ServerInfo, }, prompt_handler, service::RequestContext, tool_handler, }; -use std::{panic::AssertUnwindSafe, sync::Arc}; +use std::sync::Arc; const PREWARM_CONCURRENCY: usize = 3; const PREWARM_ACCOUNT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); @@ -72,18 +68,6 @@ fn account_log_hint(account: &str) -> String { mask_prefix_for_log(account) } -fn insert_related_task(meta: &mut Meta, task_id: &str) { - meta.0.insert( - RelatedTaskMetadata::META_KEY.to_string(), - serde_json::json!({ "taskId": task_id }), - ); -} - -fn attach_related_task(result: &mut CallToolResult, task_id: &str) { - let meta = result.meta.get_or_insert_with(Meta::new); - insert_related_task(meta, task_id); -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ProgressUpdate { completed: u64, @@ -138,7 +122,6 @@ fn make_progress_fn(meta: &Meta, peer: &Peer) -> ProgressReporter { let Some(token) = meta.get_progress_token() else { return ProgressReporter::disabled(); }; - let related_task = meta.0.get(RelatedTaskMetadata::META_KEY).cloned(); let peer = peer.clone(); let (sender, mut receiver) = tokio::sync::watch::channel(None::); let latest = Arc::new(parking_lot::Mutex::new(None::)); @@ -156,15 +139,9 @@ fn make_progress_fn(meta: &Meta, peer: &Peer) -> ProgressReporter { let Some(update) = *receiver.borrow_and_update() else { continue; }; - let mut notification = + let notification = ProgressNotificationParam::new(token.clone(), update.completed as f64) .with_total(update.total as f64); - if let Some(related_task) = related_task.clone() { - let mut meta = Meta::new(); - meta.0 - .insert(RelatedTaskMetadata::META_KEY.to_string(), related_task); - notification.meta = Some(meta); - } if peer.notify_progress(notification).await.is_err() { break; } @@ -262,7 +239,6 @@ impl Pagination { #[derive(Clone)] pub struct AgentMailServer { agentmail: Arc, - task_manager: Arc>, /// Sandbox for LLM-supplied filesystem paths (attachment reads, download /// writes). See [`file_access::FileAccessPolicy`]. file_access: Option, @@ -272,7 +248,6 @@ impl AgentMailServer { pub fn new(agentmail: crate::Agentmail) -> Self { Self { agentmail: Arc::new(agentmail), - task_manager: Arc::new(parking_lot::Mutex::new(TaskManager::new())), file_access: Some(file_access::FileAccessPolicy::from_env()), } } @@ -280,7 +255,6 @@ impl AgentMailServer { fn new_embedded(agentmail: crate::Agentmail) -> Self { Self { agentmail: Arc::new(agentmail), - task_manager: Arc::new(parking_lot::Mutex::new(TaskManager::new())), file_access: None, } } @@ -412,17 +386,14 @@ impl ServerHandler for AgentMailServer { } fn get_info(&self) -> ServerInfo { - let mut capabilities = ServerCapabilities::builder() + // No `tasks`: the SEP-1686 (MCP 2025-11-25) task surface was retired, + // measured unused — every rmcp 3.x release removes it (0.8.0). + let capabilities = ServerCapabilities::builder() .enable_tools() .enable_prompts() .enable_resources() .enable_completions() - .enable_tasks() .build(); - // `enable_tasks()` only switches the capability on. The protocol also - // requires the server to declare the supported lifecycle methods and - // which requests may be task-augmented. - capabilities.tasks = Some(TasksCapability::server_default()); ServerInfo::new(capabilities) // Without this the server announces itself as "rmcp" — rmcp's // Implementation::from_build_env() bakes in its own crate name. @@ -667,156 +638,6 @@ impl ServerHandler for AgentMailServer { ) -> Result { self.handle_complete(request).await } - - async fn enqueue_task( - &self, - request: CallToolRequestParams, - mut context: RequestContext, - ) -> Result { - let task_id = uuid::Uuid::new_v4().to_string(); - - let is_destructive = DESTRUCTIVE_TOOLS.contains(&request.name.as_ref()); - let destructive_lock = if is_destructive { - let account = extract_account(&request.arguments).ok_or_else(|| { - McpError::invalid_params( - "destructive tools require an 'account' argument for task queuing", - None, - ) - })?; - Some(self.task_manager.lock().destructive_lock(&account)) - } else { - None - }; - - // Reserve before spawning so capacity rejection cannot start work. - let task = self.task_manager.lock().reserve_task(task_id.clone())?; - - let server = self.clone(); - let completion = Arc::new(TaskCompletion::new()); - let worker_completion = Arc::clone(&completion); - // Keep transport cancellation inherited from the request while also - // giving tasks/cancel a token that reaches cooperative library work - // and spawn_blocking SQLite publication. - let task_cancel = context.ct.child_token(); - context.ct = task_cancel.clone(); - insert_related_task(&mut context.meta, &task_id); - // Captured BEFORE `context` moves into the worker: the push target. - let peer = context.peer.clone(); - let notify_id = task_id.clone(); - let notify_manager = Arc::clone(&self.task_manager); - let handle = tokio::spawn(async move { - // If destructive, acquire the per-account lock first. - // This serializes destructive tasks — the task waits here until - // any previously-enqueued destructive task on the same account - // finishes. - let _guard = match destructive_lock { - Some(ref lock) => Some(lock.lock().await), - None => None, - }; - let result = AssertUnwindSafe(server.call_tool(request, context)) - .catch_unwind() - .await - .unwrap_or_else(|_| { - Err(McpError::internal_error( - "task worker panicked while executing the tool", - None, - )) - }); - worker_completion.complete(result); - }); - - self.task_manager.lock().commit_task( - &task_id, - Arc::clone(&completion), - task_cancel, - handle, - )?; - - // Status watcher — the single producer of `notifications/tasks/status`. - // - // Spawned AFTER `commit_task` on purpose. The worker above can finish - // before the task is committed (a fast failure beats the commit to the - // lock), and `task_info` cannot resolve a task that is still only - // RESERVED — pushing from the worker therefore dropped the - // notification exactly when the task failed fastest. - // - // Waiting on the completion also unifies the two ways a task ends: - // `cancel_task` publishes a cancelled result, so cancellation wakes - // this same watcher rather than needing its own push (which would - // double-notify). - tokio::spawn(async move { - // A never-cancelled token: this watcher waits for the TASK, and - // must not be torn down by any one request's cancellation. - let _ = completion - .wait(&tokio_util::sync::CancellationToken::new()) - .await; - // Read back through `task_info`, which runs the same lazy refresh - // a `tasks/get` would — so the pushed status can never contradict - // a subsequent poll. - let Ok(task) = notify_manager.lock().task_info(¬ify_id) else { - return; - }; - let notification = rmcp::model::ServerNotification::TaskStatusNotification( - rmcp::model::TaskStatusNotification::new( - rmcp::model::TaskStatusNotificationParam::new(task), - ), - ); - if let Err(error) = peer.send_notification(notification).await { - tracing::debug!( - target: "agentmail", - task = %notify_id, - "tasks/status push failed — client gone: {error}" - ); - } - }); - - Ok(CreateTaskResult::new(task)) - } - - async fn list_tasks( - &self, - request: Option, - _context: RequestContext, - ) -> Result { - self.task_manager - .lock() - .list_page(request.as_ref().and_then(|params| params.cursor.as_deref())) - } - - async fn get_task_info( - &self, - request: GetTaskParams, - _context: RequestContext, - ) -> Result { - let task = self.task_manager.lock().task_info(&request.task_id)?; - Ok(GetTaskResult::new(task)) - } - - async fn get_task_result( - &self, - request: GetTaskPayloadParams, - context: RequestContext, - ) -> Result { - let completion = self.task_manager.lock().task_completion(&request.task_id)?; - let mut result = completion.wait(&context.ct).await?; - attach_related_task(&mut result, &request.task_id); - let value = serde_json::to_value(result) - .map_err(|error| McpError::internal_error(error.to_string(), None))?; - Ok(GetTaskPayloadResult::new(value)) - } - - async fn cancel_task( - &self, - request: CancelTaskParams, - _context: RequestContext, - ) -> Result { - // No push here: `cancel_task` publishes a cancelled result, which wakes - // the status watcher spawned in `enqueue_task`. That single watcher is - // the ONLY producer of `notifications/tasks/status`, so cancel and - // natural completion cannot double-notify. - let task = self.task_manager.lock().cancel_task(&request.task_id)?; - Ok(CancelTaskResult::new(task)) - } } // --------------------------------------------------------------------------- @@ -1324,23 +1145,6 @@ mod tests { ); } - /// `DESTRUCTIVE_TOOLS` gates task serialization and must stay in sync with - /// the per-tool annotations (MCP default: destructive unless read-only). - #[test] - fn destructive_tools_const_matches_annotations() { - for tool in AgentMailServer::tool_router().list_all() { - let destructive = tool.annotations.as_ref().is_none_or(|a| { - !a.read_only_hint.unwrap_or(false) && a.destructive_hint.unwrap_or(true) - }); - assert_eq!( - DESTRUCTIVE_TOOLS.contains(&tool.name.as_ref()), - destructive, - "DESTRUCTIVE_TOOLS drifted for `{}` — update the const or the annotations", - tool.name - ); - } - } - #[test] fn cancel_fn_reflects_token_state() { let ct = tokio_util::sync::CancellationToken::new(); diff --git a/src/mcp/tasks.rs b/src/mcp/tasks.rs deleted file mode 100644 index bb9ca87..0000000 --- a/src/mcp/tasks.rs +++ /dev/null @@ -1,1077 +0,0 @@ -//! Task management (SEP-1686): background execution of long-running tools. - -use std::sync::Arc; - -use chrono::{DateTime, SecondsFormat, Utc}; -use hashbrown::HashMap; -use rmcp::ErrorData as McpError; -use rmcp::model::{CallToolResult, ContentBlock, ListTasksResult, Task, TaskStatus}; -use tokio::sync::Notify; -use tokio::task::JoinHandle; -use tokio_util::sync::CancellationToken; - -/// Task results are retained for 24 hours from task creation. -pub(super) const TASK_TTL_MS: u64 = 86_400_000; -/// Maximum number of concurrently active tasks and enqueue reservations. -pub(super) const MAX_ACTIVE_TASKS: usize = 128; -/// Maximum accepted tasks retained at once, active and terminal combined. -/// Admission stops at this ceiling so no accepted result is evicted before -/// its advertised TTL expires. -pub(super) const MAX_TRACKED_TASKS: usize = 1_024; -/// Protocol page size for `tasks/list`. -pub(super) const TASK_PAGE_SIZE: usize = 25; - -const TASK_POLL_INTERVAL_MS: u64 = 2_000; -const TASK_CURSOR_PREFIX: &str = "am-task-v1"; - -type Clock = Arc DateTime + Send + Sync>; - -/// Tools whose `annotations.destructive_hint` is `true`. -/// Destructive tasks targeting the same account are serialized — each waits for -/// the previous destructive task to finish before starting. -pub(super) const DESTRUCTIVE_TOOLS: &[&str] = &[ - "delete_messages", - "delete_mailbox", - "delete_by_sender", - "delete_by_domain", - "delete_list_id", - "unsubscribe_message", - "reconcile_moves", - "rename_mailbox", - "update_draft", -]; - -/// Try to extract the `account` field from a tool call's JSON arguments. -pub(super) fn extract_account( - args: &Option>, -) -> Option { - args.as_ref()?.get("account")?.as_str().map(String::from) -} - -#[derive(Default)] -pub(super) struct TaskCompletion { - result: parking_lot::Mutex>>, - changed: Notify, -} - -impl TaskCompletion { - pub(super) fn new() -> Self { - Self::default() - } - - /// Publish the worker result once. A cancellation result wins races with - /// a worker that finishes after cancellation. - pub(super) fn complete(&self, result: Result) { - let mut slot = self.result.lock(); - if slot.is_none() { - *slot = Some(result); - drop(slot); - self.changed.notify_waiters(); - } - } - - fn cancel(&self, task_id: &str) { - let mut slot = self.result.lock(); - *slot = Some(Ok(CallToolResult::error(vec![ContentBlock::text( - format!("Task {task_id} was cancelled before completion."), - )]))); - drop(slot); - self.changed.notify_waiters(); - } - - /// Publish the result of a task removed at its TTL while still running. - /// The worker is aborted, so it will never publish, and without this the - /// status watcher and any blocked `tasks/result` would wait forever. A - /// worker that finished before the prune still wins: this publishes once. - fn expire(&self, task_id: &str) { - self.complete(Ok(CallToolResult::error(vec![ContentBlock::text( - format!("Task {task_id} expired before completion and was cancelled."), - )]))); - } - - fn snapshot(&self) -> Option> { - self.result.lock().clone() - } - - /// Wait until the task reaches a result-bearing terminal state. Cancelling - /// this particular `tasks/result` request does not cancel the task itself. - pub(super) async fn wait( - &self, - request_cancel: &CancellationToken, - ) -> Result { - loop { - let changed = self.changed.notified(); - tokio::pin!(changed); - changed.as_mut().enable(); - if let Some(result) = self.snapshot() { - return result; - } - tokio::select! { - () = &mut changed => {} - () = request_cancel.cancelled() => { - return Err(McpError::internal_error( - "tasks/result request was cancelled", - None, - )); - } - } - } - } -} - -pub(super) struct ManagedTask { - pub(super) meta: Task, - pub(super) completion: Arc, - pub(super) cancel: CancellationToken, - pub(super) handle: JoinHandle<()>, -} - -#[derive(Clone, Copy)] -struct TaskRecord { - created_at_millis: i64, - sequence: u64, -} - -struct TaskReservation { - meta: Task, - record: TaskRecord, -} - -#[derive(Clone, Copy)] -struct CursorSecret { - mask: u64, - tag: u64, -} - -pub(super) struct TaskManager { - pub(super) tasks: HashMap, - task_records: HashMap, - reservations: HashMap, - next_sequence: u64, - clock: Clock, - cursor_secret: CursorSecret, - /// Per-account async mutex that serializes destructive tasks. - /// These must be `tokio::sync::Mutex` because the guard is held across - /// the entire `call_tool().await` execution. - /// Non-destructive tasks bypass these locks entirely. - pub(super) destructive_locks: HashMap>>, -} - -impl TaskManager { - pub(super) fn new() -> Self { - let secret = uuid::Uuid::new_v4().as_u128(); - Self::with_clock( - Arc::new(Utc::now), - CursorSecret { - mask: secret as u64, - tag: (secret >> 64) as u64, - }, - ) - } - - fn with_clock(clock: Clock, cursor_secret: CursorSecret) -> Self { - Self { - tasks: HashMap::new(), - task_records: HashMap::new(), - reservations: HashMap::new(), - next_sequence: 1, - clock, - cursor_secret, - destructive_locks: HashMap::new(), - } - } - - /// Reserve bounded capacity before spawning work. - /// - /// The returned metadata already contains the creation-based TTL. Call - /// [`Self::commit_task`] immediately after spawning the worker. - pub(super) fn reserve_task(&mut self, task_id: String) -> Result { - self.refresh_all(); - - if self.tasks.len() + self.reservations.len() >= MAX_TRACKED_TASKS { - return Err(McpError::internal_error( - format!("task retention capacity reached ({MAX_TRACKED_TASKS} tasks)"), - None, - )); - } - let active = self - .tasks - .values() - .filter(|managed| !is_terminal(&managed.meta.status)) - .count(); - if active + self.reservations.len() >= MAX_ACTIVE_TASKS { - return Err(McpError::internal_error( - format!("task capacity reached ({MAX_ACTIVE_TASKS} active tasks)"), - None, - )); - } - if self.tasks.contains_key(&task_id) || self.reservations.contains_key(&task_id) { - return Err(McpError::internal_error("duplicate task id", None)); - } - - let now = self.now(); - let timestamp = format_timestamp(&now); - let task = Task::new( - task_id.clone(), - TaskStatus::Working, - timestamp.clone(), - timestamp, - ) - .with_ttl(TASK_TTL_MS) - .with_poll_interval(TASK_POLL_INTERVAL_MS); - let record = TaskRecord { - created_at_millis: now.timestamp_millis(), - sequence: self.next_sequence, - }; - self.next_sequence = self.next_sequence.wrapping_add(1); - self.reservations.insert( - task_id, - TaskReservation { - meta: task.clone(), - record, - }, - ); - Ok(task) - } - - /// Attach a spawned worker to a prior capacity reservation. - pub(super) fn commit_task( - &mut self, - task_id: &str, - completion: Arc, - cancel: CancellationToken, - handle: JoinHandle<()>, - ) -> Result { - self.prune_expired(); - let Some(reservation) = self.reservations.remove(task_id) else { - cancel.cancel(); - handle.abort(); - return Err(McpError::internal_error( - "task reservation is missing or expired", - None, - )); - }; - - let task = reservation.meta.clone(); - self.task_records - .insert(task_id.to_string(), reservation.record); - self.tasks.insert( - task_id.to_string(), - ManagedTask { - meta: reservation.meta, - completion, - cancel, - handle, - }, - ); - Ok(task) - } - - /// Get or create the destructive-task serialization lock for an account. - pub(super) fn destructive_lock(&mut self, account: &str) -> Arc> { - self.prune_expired(); - self.destructive_locks - .entry(account.to_string()) - .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) - .clone() - } - - /// Remove expired entries and cancel their work before releasing metadata. - pub(super) fn prune_expired(&mut self) { - let now_millis = self.now().timestamp_millis(); - - let expired_tasks: Vec = self - .tasks - .iter() - .filter_map(|(task_id, managed)| { - let created_at_millis = self.task_records.get(task_id).map_or_else( - || parse_created_at(&managed.meta), - |record| record.created_at_millis, - ); - is_expired(created_at_millis, now_millis).then(|| task_id.clone()) - }) - .collect(); - - for task_id in expired_tasks { - if let Some(managed) = self.tasks.remove(&task_id) - && !is_terminal(&managed.meta.status) - { - managed.cancel.cancel(); - managed.completion.expire(&task_id); - managed.handle.abort(); - } - self.task_records.remove(&task_id); - } - - self.reservations - .retain(|_, reservation| !is_expired(reservation.record.created_at_millis, now_millis)); - self.destructive_locks - .retain(|_, lock| Arc::strong_count(lock) > 1); - } - - /// Check if a specific task's spawned future has finished and update its - /// metadata accordingly. - pub(super) fn refresh_status(&mut self, task_id: &str) { - self.prune_expired(); - let updated_at = format_timestamp(&self.now()); - if let Some(managed) = self.tasks.get_mut(task_id) { - refresh_managed_task(managed, &updated_at); - } - } - - /// Refresh the status of all tracked tasks. - pub(super) fn refresh_all(&mut self) { - self.prune_expired(); - let updated_at = format_timestamp(&self.now()); - for managed in self.tasks.values_mut() { - refresh_managed_task(managed, &updated_at); - } - } - - /// Return current task metadata after expiry and completion checks. - pub(super) fn task_info(&mut self, task_id: &str) -> Result { - self.refresh_status(task_id); - self.tasks - .get(task_id) - .map(|managed| managed.meta.clone()) - .ok_or_else(|| unknown_task(task_id)) - } - - /// Return the completion signal used by blocking `tasks/result` calls. - pub(super) fn task_completion( - &mut self, - task_id: &str, - ) -> Result, McpError> { - self.refresh_status(task_id); - self.tasks - .get(task_id) - .map(|managed| Arc::clone(&managed.completion)) - .ok_or_else(|| unknown_task(task_id)) - } - - /// Cancel a non-terminal task and return its retained metadata. - pub(super) fn cancel_task(&mut self, task_id: &str) -> Result { - self.refresh_status(task_id); - let updated_at = format_timestamp(&self.now()); - let managed = self - .tasks - .get_mut(task_id) - .ok_or_else(|| unknown_task(task_id))?; - if is_terminal(&managed.meta.status) { - return Err(McpError::invalid_params( - format!("task is already terminal: {task_id}"), - None, - )); - } - managed.cancel.cancel(); - managed.completion.cancel(task_id); - managed.handle.abort(); - managed.meta.status = TaskStatus::Cancelled; - managed.meta.status_message = Some("The task was cancelled by request.".to_string()); - managed.meta.last_updated_at = updated_at; - Ok(managed.meta.clone()) - } - - /// List retained tasks newest-first with a process-local opaque cursor. - pub(super) fn list_page(&mut self, cursor: Option<&str>) -> Result { - self.refresh_all(); - let before_sequence = cursor.map(|value| self.decode_cursor(value)).transpose()?; - - let mut entries: Vec<(&String, &ManagedTask)> = self.tasks.iter().collect(); - if let Some(before_sequence) = before_sequence { - entries.retain(|(task_id, managed)| { - self.record_for(task_id, managed).sequence < before_sequence - }); - } - entries.sort_by(|(left_id, left), (right_id, right)| { - let left_record = self.record_for(left_id, left); - let right_record = self.record_for(right_id, right); - right_record - .sequence - .cmp(&left_record.sequence) - .then_with(|| { - right_record - .created_at_millis - .cmp(&left_record.created_at_millis) - }) - .then_with(|| right_id.cmp(left_id)) - }); - - let has_more = entries.len() > TASK_PAGE_SIZE; - let page = entries - .iter() - .take(TASK_PAGE_SIZE) - .copied() - .collect::>(); - let next_cursor = has_more.then(|| { - let (task_id, managed) = page - .last() - .expect("a page with more rows always has a final row"); - self.encode_cursor(self.record_for(task_id, managed).sequence) - }); - let tasks = page - .into_iter() - .map(|(_, managed)| managed.meta.clone()) - .collect(); - - let mut result = ListTasksResult::new(tasks); - result.next_cursor = next_cursor; - Ok(result) - } - - fn now(&self) -> DateTime { - (self.clock)() - } - - fn record_for(&self, task_id: &str, managed: &ManagedTask) -> TaskRecord { - self.task_records - .get(task_id) - .copied() - .unwrap_or(TaskRecord { - created_at_millis: parse_created_at(&managed.meta), - sequence: 0, - }) - } - - fn encode_cursor(&self, before_sequence: u64) -> String { - let masked = before_sequence ^ self.cursor_secret.mask; - let tag = mix64(masked ^ self.cursor_secret.tag); - format!("{TASK_CURSOR_PREFIX}.{masked:016x}.{tag:016x}") - } - - fn decode_cursor(&self, cursor: &str) -> Result { - let mut parts = cursor.split('.'); - let Some(prefix) = parts.next() else { - return Err(invalid_cursor()); - }; - let Some(masked) = parts.next() else { - return Err(invalid_cursor()); - }; - let Some(tag) = parts.next() else { - return Err(invalid_cursor()); - }; - if prefix != TASK_CURSOR_PREFIX || parts.next().is_some() { - return Err(invalid_cursor()); - } - - let masked = u64::from_str_radix(masked, 16).map_err(|_| invalid_cursor())?; - let tag = u64::from_str_radix(tag, 16).map_err(|_| invalid_cursor())?; - if tag != mix64(masked ^ self.cursor_secret.tag) { - return Err(invalid_cursor()); - } - Ok(masked ^ self.cursor_secret.mask) - } -} - -fn refresh_managed_task(managed: &mut ManagedTask, updated_at: &str) { - if is_terminal(&managed.meta.status) { - return; - } - - let result = managed.completion.snapshot(); - if result.is_none() && !managed.handle.is_finished() { - return; - } - if result.is_none() { - managed.completion.complete(Err(McpError::internal_error( - "task worker ended without publishing a result", - None, - ))); - } - let result = managed.completion.snapshot(); - managed.meta.status = match result.as_ref() { - Some(Ok(call_result)) if call_result.is_error == Some(true) => TaskStatus::Failed, - Some(Ok(_)) => TaskStatus::Completed, - Some(Err(_)) | None => TaskStatus::Failed, - }; - managed.meta.status_message = match result { - Some(Err(error)) => Some(error.message.to_string()), - Some(Ok(ref call_result)) if call_result.is_error == Some(true) => call_result - .content - .first() - .and_then(|content| content.as_text()) - .map(|text| text.text.clone()), - _ => None, - }; - managed.meta.last_updated_at = updated_at.to_string(); -} - -fn is_terminal(status: &TaskStatus) -> bool { - matches!( - status, - TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Cancelled - ) -} - -fn is_expired(created_at_millis: i64, now_millis: i64) -> bool { - now_millis.saturating_sub(created_at_millis) >= TASK_TTL_MS as i64 -} - -fn parse_created_at(task: &Task) -> i64 { - DateTime::parse_from_rfc3339(&task.created_at) - .map(|timestamp| timestamp.timestamp_millis()) - .unwrap_or(i64::MIN) -} - -fn format_timestamp(timestamp: &DateTime) -> String { - timestamp.to_rfc3339_opts(SecondsFormat::Millis, true) -} - -fn unknown_task(task_id: &str) -> McpError { - McpError::invalid_params(format!("unknown or expired task: {task_id}"), None) -} - -fn invalid_cursor() -> McpError { - McpError::invalid_params("invalid task cursor", None) -} - -fn mix64(mut value: u64) -> u64 { - value ^= value >> 30; - value = value.wrapping_mul(0xbf58_476d_1ce4_e5b9); - value ^= value >> 27; - value = value.wrapping_mul(0x94d0_49bb_1331_11eb); - value ^ (value >> 31) -} - -#[cfg(test)] -mod tests { - use std::sync::atomic::{AtomicI64, Ordering}; - - use rmcp::model::ContentBlock; - use tokio::sync::oneshot; - - use super::*; - - const START_MILLIS: i64 = 1_750_000_000_000; - - #[derive(Clone)] - struct TestClock(Arc); - - impl TestClock { - fn new() -> Self { - Self(Arc::new(AtomicI64::new(START_MILLIS))) - } - - fn advance(&self, millis: u64) { - self.0.fetch_add(millis as i64, Ordering::SeqCst); - } - - fn clock(&self) -> Clock { - let millis = Arc::clone(&self.0); - Arc::new(move || { - DateTime::from_timestamp_millis(millis.load(Ordering::SeqCst)) - .expect("test timestamp should be valid") - }) - } - } - - fn manager(clock: &TestClock) -> TaskManager { - TaskManager::with_clock( - clock.clock(), - CursorSecret { - mask: 0x1234_5678_9abc_def0, - tag: 0x0fed_cba9_8765_4321, - }, - ) - } - - fn commit_completed(manager: &mut TaskManager, task_id: &str) -> Task { - manager - .reserve_task(task_id.to_string()) - .expect("task reservation should succeed"); - let result = CallToolResult::success(vec![ContentBlock::text(task_id.to_string())]); - let completion = Arc::new(TaskCompletion::new()); - completion.complete(Ok(result)); - let cancel = CancellationToken::new(); - let handle = tokio::spawn(async {}); - let task = manager - .commit_task(task_id, completion, cancel, handle) - .expect("task commit should succeed"); - manager - .tasks - .get_mut(task_id) - .expect("committed task should exist") - .meta - .status = TaskStatus::Completed; - task - } - - fn commit_running(manager: &mut TaskManager, task_id: &str) -> CancellationToken { - manager - .reserve_task(task_id.to_string()) - .expect("task reservation should succeed"); - let completion = Arc::new(TaskCompletion::new()); - let cancel = CancellationToken::new(); - let worker_cancel = cancel.clone(); - let handle = tokio::spawn(async move { - worker_cancel.cancelled().await; - }); - manager - .commit_task(task_id, completion, cancel.clone(), handle) - .expect("task commit should succeed"); - cancel - } - - #[tokio::test] - async fn reserve_task_should_advertise_creation_based_24_hour_ttl() { - let clock = TestClock::new(); - let mut manager = manager(&clock); - - let task = manager - .reserve_task("ttl".to_string()) - .expect("task reservation should succeed"); - - assert_eq!(task.ttl, Some(TASK_TTL_MS)); - } - - #[tokio::test] - async fn prune_expired_should_cancel_running_work_at_ttl_boundary() { - let clock = TestClock::new(); - let mut manager = manager(&clock); - let cancellation = commit_running(&mut manager, "running"); - - clock.advance(TASK_TTL_MS); - let error = manager - .task_info("running") - .expect_err("expired task should be unavailable"); - - assert!( - cancellation.is_cancelled() && error.message.contains("expired"), - "expired running task should be cancelled and reported unavailable" - ); - } - - /// The status watcher waits on the completion, and an expired task's - /// worker is aborted before it can publish — so expiry must publish, or - /// the watcher (and any blocked `tasks/result`) waits forever. - #[tokio::test] - async fn prune_expired_should_publish_a_result_for_running_work() { - let clock = TestClock::new(); - let mut manager = manager(&clock); - commit_running(&mut manager, "abandoned"); - let completion = manager - .task_completion("abandoned") - .expect("task should be present"); - let watcher = tokio::spawn({ - let completion = Arc::clone(&completion); - async move { completion.wait(&CancellationToken::new()).await } - }); - - clock.advance(TASK_TTL_MS); - manager.prune_expired(); - let result = tokio::time::timeout(std::time::Duration::from_secs(5), watcher) - .await - .expect("expiry must wake the watcher") - .expect("watcher should not panic") - .expect("an expired task ends with a result, not an error"); - - assert_eq!(result.is_error, Some(true)); - assert!( - format!("{:?}", result.content).contains("expired"), - "{result:?}" - ); - } - - #[tokio::test] - async fn expiry_should_be_measured_from_creation_not_last_update() { - let clock = TestClock::new(); - let mut manager = manager(&clock); - commit_running(&mut manager, "cancelled"); - - clock.advance(TASK_TTL_MS / 2); - manager - .cancel_task("cancelled") - .expect("live task cancellation should succeed"); - clock.advance(TASK_TTL_MS / 2); - - assert!( - manager.task_info("cancelled").is_err(), - "lastUpdatedAt must not extend retention" - ); - } - - #[tokio::test] - async fn task_completion_should_block_until_worker_publishes() { - let completion = Arc::new(TaskCompletion::new()); - let waiting_completion = Arc::clone(&completion); - let waiter = - tokio::spawn(async move { waiting_completion.wait(&CancellationToken::new()).await }); - tokio::task::yield_now().await; - assert!( - !waiter.is_finished(), - "tasks/result must block while work is active" - ); - - completion.complete(Ok(CallToolResult::success(vec![ContentBlock::text( - "done", - )]))); - let result = waiter - .await - .expect("waiter should not panic") - .expect("published result should be returned"); - - assert_eq!(result.is_error, Some(false)); - } - - #[tokio::test] - async fn cancelled_task_should_retain_a_final_error_result() { - let clock = TestClock::new(); - let mut manager = manager(&clock); - commit_running(&mut manager, "cancelled-result"); - let completion = manager - .task_completion("cancelled-result") - .expect("task should be present"); - - let task = manager - .cancel_task("cancelled-result") - .expect("active task should be cancellable"); - let result = completion - .wait(&CancellationToken::new()) - .await - .expect("cancelled task should have a final result"); - - assert!( - task.status == TaskStatus::Cancelled && result.is_error == Some(true), - "cancelled status and final isError result must agree" - ); - } - - #[tokio::test] - async fn cancelling_terminal_task_should_return_invalid_params() { - let clock = TestClock::new(); - let mut manager = manager(&clock); - commit_completed(&mut manager, "completed"); - - let error = manager - .cancel_task("completed") - .expect_err("terminal tasks cannot be cancelled"); - - assert_eq!(error.code, rmcp::model::ErrorCode::INVALID_PARAMS); - } - - #[tokio::test] - async fn completed_tasks_should_not_consume_active_capacity() { - let clock = TestClock::new(); - let mut manager = manager(&clock); - for index in 0..MAX_ACTIVE_TASKS { - commit_completed(&mut manager, &format!("task-{index:03}")); - } - - let reservation = manager.reserve_task("new-active".to_string()); - - assert!( - reservation.is_ok(), - "terminal retention must not block new work" - ); - } - - #[tokio::test] - async fn retention_capacity_should_reject_admission_without_evicting_promised_results() { - let clock = TestClock::new(); - let mut manager = manager(&clock); - for index in 0..MAX_TRACKED_TASKS { - commit_completed(&mut manager, &format!("retained-{index:04}")); - } - - let error = manager - .reserve_task("overflow".to_string()) - .expect_err("retention capacity should reject new tasks"); - - assert!(error.message.contains("retention capacity")); - assert_eq!(error.code, rmcp::model::ErrorCode::INTERNAL_ERROR); - assert!( - manager.task_info("retained-0000").is_ok(), - "accepted task results must remain available until their TTL" - ); - } - - #[tokio::test] - async fn active_tasks_should_enforce_capacity() { - let clock = TestClock::new(); - let mut manager = manager(&clock); - for index in 0..MAX_ACTIVE_TASKS { - commit_running(&mut manager, &format!("task-{index:03}")); - } - - let error = manager - .reserve_task("overflow".to_string()) - .expect_err("active capacity should be enforced"); - - assert!(error.message.contains("active tasks")); - assert_eq!(error.code, rmcp::model::ErrorCode::INTERNAL_ERROR); - } - - #[tokio::test] - async fn task_result_should_be_repeatable_until_expiry() { - let clock = TestClock::new(); - let mut manager = manager(&clock); - commit_completed(&mut manager, "repeatable"); - - let completion = manager - .task_completion("repeatable") - .expect("task completion should be available"); - let first = completion - .wait(&CancellationToken::new()) - .await - .expect("first result retrieval should succeed"); - let second = completion - .wait(&CancellationToken::new()) - .await - .expect("second result retrieval should succeed"); - - assert_eq!(first, second); - } - - #[tokio::test] - async fn list_page_should_return_newest_first_in_pages_of_25() { - let clock = TestClock::new(); - let mut manager = manager(&clock); - for index in 0..27 { - commit_completed(&mut manager, &format!("task-{index:02}")); - clock.advance(1); - } - - let first = manager - .list_page(None) - .expect("first task page should succeed"); - let second = manager - .list_page(first.next_cursor.as_deref()) - .expect("second task page should succeed"); - - assert!( - first.tasks.len() == TASK_PAGE_SIZE - && first.tasks[0].task_id == "task-26" - && second.tasks.len() == 2 - && second.tasks[0].task_id == "task-01" - && second.next_cursor.is_none(), - "task pages should be bounded, newest-first, and complete" - ); - } - - #[tokio::test] - async fn list_cursor_should_not_shift_when_new_tasks_arrive_between_pages() { - let clock = TestClock::new(); - let mut manager = manager(&clock); - for index in 0..27 { - commit_completed(&mut manager, &format!("task-{index:02}")); - clock.advance(1); - } - - let first = manager.list_page(None).expect("first page"); - commit_completed(&mut manager, "newer-task"); - let second = manager - .list_page(first.next_cursor.as_deref()) - .expect("second page"); - - assert_eq!( - second - .tasks - .iter() - .map(|task| task.task_id.as_str()) - .collect::>(), - vec!["task-01", "task-00"], - "the opaque cursor must continue after the prior page boundary" - ); - } - - #[tokio::test] - async fn list_page_should_reject_malformed_or_tampered_cursor() { - let clock = TestClock::new(); - let mut manager = manager(&clock); - - let error = manager - .list_page(Some("am-task-v1.0000000000000001.0000000000000001")) - .expect_err("tampered cursor should be rejected"); - - assert!(error.message.contains("invalid task cursor")); - } - - #[test] - fn destructive_lock_should_return_same_mutex_per_account() { - let clock = TestClock::new(); - let mut manager = manager(&clock); - - let work_first = manager.destructive_lock("work"); - let work_second = manager.destructive_lock("work"); - let personal = manager.destructive_lock("personal"); - - assert!( - Arc::ptr_eq(&work_first, &work_second) && !Arc::ptr_eq(&work_first, &personal), - "same account shares one serialization lock; different accounts get distinct locks" - ); - } - - /// Two destructive tasks on the same account run strictly one after the - /// other. Mirrors `enqueue_task`'s wiring: each spawned worker holds the - /// account's destructive lock across its tool call and is registered via - /// `reserve_task`/`commit_task`. - #[tokio::test] - async fn destructive_tasks_should_serialize_on_same_account() { - let clock = TestClock::new(); - let mut manager = manager(&clock); - let lock = manager.destructive_lock("work"); - let events: Arc>> = - Arc::new(parking_lot::Mutex::new(Vec::new())); - let (started_tx, started_rx) = oneshot::channel(); - let (finish_tx, finish_rx) = oneshot::channel(); - let (done_tx, mut done_rx) = oneshot::channel(); - - manager - .reserve_task("first".to_string()) - .expect("task reservation should succeed"); - let first_completion = Arc::new(TaskCompletion::new()); - let worker_completion = Arc::clone(&first_completion); - let worker_lock = Arc::clone(&lock); - let worker_events = Arc::clone(&events); - let first_handle = tokio::spawn(async move { - let _guard = worker_lock.lock().await; - worker_events.lock().push("first:acquired"); - started_tx - .send(()) - .expect("test should await the start signal"); - finish_rx.await.expect("test should signal completion"); - worker_events.lock().push("first:releasing"); - worker_completion.complete(Ok(CallToolResult::success(vec![ContentBlock::text( - "first", - )]))); - }); - manager - .commit_task( - "first", - first_completion, - CancellationToken::new(), - first_handle, - ) - .expect("task commit should succeed"); - - started_rx - .await - .expect("first worker should acquire the lock"); - // The first worker provably holds the lock; only now enqueue the - // second so the acquisition order is fixed. - manager - .reserve_task("second".to_string()) - .expect("task reservation should succeed"); - let second_completion = Arc::new(TaskCompletion::new()); - let worker_completion = Arc::clone(&second_completion); - let worker_lock = Arc::clone(&lock); - let worker_events = Arc::clone(&events); - let second_handle = tokio::spawn(async move { - let _guard = worker_lock.lock().await; - worker_events.lock().push("second:acquired"); - worker_completion.complete(Ok(CallToolResult::success(vec![ContentBlock::text( - "second", - )]))); - done_tx.send(()).expect("test should await the done signal"); - }); - manager - .commit_task( - "second", - second_completion, - CancellationToken::new(), - second_handle, - ) - .expect("task commit should succeed"); - - // Give the second worker every chance to (incorrectly) run: on the - // current-thread test runtime each yield lets every ready task reach - // its next await point. - for _ in 0..8 { - tokio::task::yield_now().await; - } - assert!( - lock.try_lock().is_err(), - "account lock should still be held by the first worker" - ); - assert!( - matches!(done_rx.try_recv(), Err(oneshot::error::TryRecvError::Empty)), - "second destructive task must not run while the first holds the lock" - ); - - finish_tx - .send(()) - .expect("first worker should be waiting for the finish signal"); - done_rx - .await - .expect("second worker should acquire after the first releases"); - - (&mut manager - .tasks - .get_mut("first") - .expect("first task should be tracked") - .handle) - .await - .expect("first worker should not panic"); - (&mut manager - .tasks - .get_mut("second") - .expect("second task should be tracked") - .handle) - .await - .expect("second worker should not panic"); - - assert_eq!( - events.lock().clone(), - vec!["first:acquired", "first:releasing", "second:acquired"], - "second destructive task starts only after the first releases the account lock" - ); - } - - #[tokio::test] - async fn destructive_lock_should_not_block_other_accounts() { - let clock = TestClock::new(); - let mut manager = manager(&clock); - let work = manager.destructive_lock("work"); - let personal = manager.destructive_lock("personal"); - - let _work_guard = work.lock().await; - - assert!( - personal.try_lock().is_ok(), - "holding one account's destructive lock must not block another account" - ); - } - - #[test] - fn prune_expired_should_drop_destructive_locks_once_unused() { - let clock = TestClock::new(); - let mut manager = manager(&clock); - - // The local clone stands in for a worker's clone: retention depends - // only on the Arc's strong count. - let lock = manager.destructive_lock("work"); - manager.prune_expired(); - assert!( - manager.destructive_locks.contains_key("work"), - "an outstanding clone keeps the account lock registered" - ); - - drop(lock); - manager.prune_expired(); - assert!( - !manager.destructive_locks.contains_key("work"), - "unused destructive locks are pruned" - ); - } - - #[test] - fn extract_account_should_read_string_account_argument() { - let mut args = serde_json::Map::new(); - args.insert("account".to_string(), serde_json::json!("work")); - - assert_eq!(extract_account(&Some(args)).as_deref(), Some("work")); - } - - #[test] - fn extract_account_should_reject_missing_or_non_string_account() { - let mut non_string = serde_json::Map::new(); - non_string.insert("account".to_string(), serde_json::json!(7)); - - assert!( - extract_account(&None).is_none() - && extract_account(&Some(serde_json::Map::new())).is_none() - && extract_account(&Some(non_string)).is_none(), - "account extraction should require a string account value" - ); - } -} diff --git a/src/mcp/tools_read.rs b/src/mcp/tools_read.rs index a613694..c1c0291 100644 --- a/src/mcp/tools_read.rs +++ b/src/mcp/tools_read.rs @@ -177,8 +177,7 @@ impl AgentMailServer { name = "search_messages", output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid search_messages output schema"), description = "Search one required mailbox with filters: senderContains, subjectContains, toContains, query (IMAP full-text), read, flagged, headerKey/headerValueContains, since/before (YYYY-MM-DD), and largerThan/smallerThan (bytes). Filters are AND-combined. Returns metadata-only results newest-first with the mailbox UIDVALIDITY and pagination data (offset, limit, total, nextOffset). Every row's message rides the result as a resource_link — the markdown body, plus that message's /info for headers, source, and attachments. Follow those links; the result carries no URI in its JSON. Get mailbox names from list_mailboxes. Defaults: offset=0, limit=25 (max 50).", - annotations(title = "Search Messages", read_only_hint = true), - execution(task_support = "optional") + annotations(title = "Search Messages", read_only_hint = true) )] async fn search_messages_tool( &self, @@ -230,8 +229,7 @@ impl AgentMailServer { name = "list_flags", output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid list_flags output schema"), description = "List all IMAP flags in use with counts per flag (e.g. \\Seen: 1234, \\Flagged: 56). Omit mailbox for account-wide discovery: one selectable \\All mailbox is preferred, otherwise selectable storage mailboxes are enumerated without Trash/Junk/Drafts or virtual aggregate views. Resolves Apple Mail $MailFlagBit color flags to color names (red, orange, yellow, green, blue, purple, gray). An account-wide scan lists in skipped the mailboxes it did not cover — one it couldn't open, or every mailbox after a lost connection; their messages are in none of the counts.", - annotations(title = "List Flags", read_only_hint = true), - execution(task_support = "optional") + annotations(title = "List Flags", read_only_hint = true) )] async fn list_flags_tool( &self, @@ -262,8 +260,7 @@ impl AgentMailServer { name = "find_attachments", output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid find_attachments output schema"), description = "Find messages with attachments (multipart/mixed or multipart/related), newest-first. Each hit includes mailbox, UIDVALIDITY, UID, and date so account-wide UID collisions are unambiguous, and rides the result as a resource_link. Omit mailbox for account-wide discovery: one selectable \\All mailbox is preferred, otherwise selectable storage mailboxes are enumerated without excluded or virtual views. Defaults: offset=0, limit=25 (max 100). To save files, pass a hit's mailbox, uid, and uidValidity as expectedUidValidity to download_attachments. An account-wide scan lists in skipped the mailboxes it did not cover; their messages are not in total.", - annotations(title = "Find Attachments", read_only_hint = true), - execution(task_support = "optional") + annotations(title = "Find Attachments", read_only_hint = true) )] async fn find_attachments_tool( &self, @@ -298,8 +295,7 @@ impl AgentMailServer { name = "top_senders", output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid top_senders output schema"), description = "List the senders who email you most, by message count. Omit mailbox for account-wide discovery: one selectable \\All mailbox is scanned alone when available; otherwise selectable storage mailboxes are enumerated, virtual/excluded views are skipped, and Message-ID deduplicates across folders. Excludes your own address and groups by exact address + display name. Every row has a safe nested sample {mailbox, uidValidity, uid} for inspection, linked from the result as a resource_link; pass its exact address and displayName to delete_by_sender or move_by_sender. Live offset pagination defaults to 10 rows (max 100).", - annotations(title = "Top Senders", read_only_hint = true), - execution(task_support = "optional") + annotations(title = "Top Senders", read_only_hint = true) )] async fn top_senders_tool( &self, @@ -334,8 +330,7 @@ impl AgentMailServer { name = "top_domains", output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid top_domains output schema"), description = "List exact canonical Header From domains by message count. A parent and each subdomain are separate rows: example.com never includes mail.example.com. Each row supplies registrableDomain and subdomain as Public Suffix List context, plus a UIDVALIDITY-safe sample and its decoded subject when available. Header From is organizational metadata, not proof of DKIM ownership. Omit mailbox for account-wide discovery. Live offset pagination defaults to 20 rows (max 100); use the exact domain value with delete_by_domain or move_by_domain.", - annotations(title = "Top Domains", read_only_hint = true), - execution(task_support = "optional") + annotations(title = "Top Domains", read_only_hint = true) )] async fn top_domains_tool( &self, @@ -370,8 +365,7 @@ impl AgentMailServer { name = "top_subscriptions", output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid top_subscriptions output schema"), description = "List bulk/marketing subscriptions by message count. Omit mailbox for account-wide discovery, which prefers one selectable \\All mailbox and otherwise enumerates storage mailboxes. Rows are grouped only by normalized sender email; display names and List-Id values do not split a sender's row. Rows are sorted by advertised one-click syntax, then count. Each has a nested sample {mailbox, uidValidity, uid}, linked from the result as a resource_link, and, when available, the sample message's decoded subject — what this subscription's mail actually looks like. Map the sample to move_subscription to file exact bulk-mail matches without an unsubscribe request, or to unsubscribe_message for a consented verified unsubscribe. advertisedOneClick is syntactic only—the unsubscribe action re-fetches the complete message and verifies DKIM. Live offset pagination defaults to 10 rows (max 100).", - annotations(title = "Top Subscriptions", read_only_hint = true), - execution(task_support = "optional") + annotations(title = "Top Subscriptions", read_only_hint = true) )] async fn top_subscriptions_tool( &self, @@ -406,8 +400,7 @@ impl AgentMailServer { name = "top_mailing_lists", output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid top_mailing_lists output schema"), description = "List mailing lists by normalized List-Id (RFC 2919), highest volume first, including List-Id-only messages and grouping across senders. Each row includes a bounded sender preview, senderCount, a UIDVALIDITY-safe nested sample for inspection, and, when available, the sample message's decoded subject — what this list's mail actually looks like. Omit mailbox for account-wide discovery, which prefers one selectable \\All mailbox and otherwise enumerates storage mailboxes. Live offset pagination defaults to 10 rows (max 100). Use delete_list_id or move_list_id with an approved exact listId.", - annotations(title = "Top Mailing Lists", read_only_hint = true), - execution(task_support = "optional") + annotations(title = "Top Mailing Lists", read_only_hint = true) )] async fn top_mailing_lists_tool( &self, @@ -466,8 +459,7 @@ impl AgentMailServer { title = "Preview Thread Record", read_only_hint = true, open_world_hint = false - ), - execution(task_support = "optional") + ) )] async fn preview_thread_record_tool( &self, diff --git a/src/mcp/tools_write.rs b/src/mcp/tools_write.rs index fb31650..5fe4d0b 100644 --- a/src/mcp/tools_write.rs +++ b/src/mcp/tools_write.rs @@ -703,8 +703,7 @@ impl AgentMailServer { title = "Delete Messages", destructive_hint = true, idempotent_hint = true - ), - execution(task_support = "optional") + ) )] async fn delete_messages_tool( &self, @@ -754,8 +753,7 @@ impl AgentMailServer { name = "delete_by_sender", output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid delete_by_sender output schema"), description = "Delete all messages from an exact sender identity. Pass the address and displayName exactly as returned by a top_senders row; matching is exact on both fields, confirmed live before any deletion. Omit mailbox to enumerate selectable storage mailboxes; mutation planning excludes Trash/Junk/Drafts and never writes through \\All, \\Flagged, or \\Important aggregate views. Do not use this for mailing-list cleanup (use delete_list_id or unsubscribe_message cleanup).", - annotations(title = "Delete by Sender", destructive_hint = true), - execution(task_support = "optional") + annotations(title = "Delete by Sender", destructive_hint = true) )] async fn delete_by_sender_tool( &self, @@ -797,8 +795,7 @@ impl AgentMailServer { title = "Delete by Exact Domain", destructive_hint = true, idempotent_hint = true - ), - execution(task_support = "optional") + ) )] async fn delete_by_domain_tool( &self, @@ -840,8 +837,7 @@ impl AgentMailServer { read_only_hint = false, destructive_hint = false, idempotent_hint = true - ), - execution(task_support = "optional") + ) )] async fn download_attachments_tool( &self, @@ -889,8 +885,7 @@ impl AgentMailServer { read_only_hint = false, destructive_hint = false, open_world_hint = true - ), - execution(task_support = "optional") + ) )] async fn download_message_source_tool( &self, @@ -935,8 +930,7 @@ impl AgentMailServer { read_only_hint = false, destructive_hint = false, open_world_hint = true - ), - execution(task_support = "optional") + ) )] async fn download_thread_tool( &self, @@ -1058,8 +1052,7 @@ impl AgentMailServer { destructive_hint = false, idempotent_hint = false, open_world_hint = true - ), - execution(task_support = "optional") + ) )] async fn export_thread_record_tool( &self, @@ -1290,8 +1283,7 @@ impl AgentMailServer { title = "Unsubscribe from Mailing List", destructive_hint = true, open_world_hint = true - ), - execution(task_support = "optional") + ) )] async fn unsubscribe_message_tool( &self, @@ -1332,8 +1324,7 @@ impl AgentMailServer { name = "delete_list_id", output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid delete_list_id output schema"), description = "Delete all messages with a specific List-Id. Identifies the list by its exact List-Id value from top_mailing_lists and deletes matching messages regardless of sender. Omit mailbox to enumerate selectable storage mailboxes; mutation planning excludes Trash/Junk/Drafts and never writes through \\All, \\Flagged, or \\Important aggregate views.", - annotations(title = "Delete Mailing List by List-Id", destructive_hint = true), - execution(task_support = "optional") + annotations(title = "Delete Mailing List by List-Id", destructive_hint = true) )] async fn delete_list_id_tool( &self, @@ -1370,8 +1361,7 @@ impl AgentMailServer { title = "Move Mailing List by List-Id", read_only_hint = false, destructive_hint = false - ), - execution(task_support = "optional") + ) )] async fn move_list_id_tool( &self, @@ -1411,8 +1401,7 @@ impl AgentMailServer { title = "Move by Sender", read_only_hint = false, destructive_hint = false - ), - execution(task_support = "optional") + ) )] async fn move_by_sender_tool( &self, @@ -1457,8 +1446,7 @@ impl AgentMailServer { read_only_hint = false, destructive_hint = false, idempotent_hint = true - ), - execution(task_support = "optional") + ) )] async fn move_by_domain_tool( &self, @@ -1501,8 +1489,7 @@ impl AgentMailServer { title = "Move Top Subscription", read_only_hint = false, destructive_hint = false - ), - execution(task_support = "optional") + ) )] async fn move_subscription_tool( &self, @@ -1546,8 +1533,7 @@ impl AgentMailServer { title = "Reconcile Pending Moves", destructive_hint = true, idempotent_hint = true - ), - execution(task_support = "optional") + ) )] async fn reconcile_moves_tool( &self, diff --git a/tests/mcp_integration.rs b/tests/mcp_integration.rs index 3f8a1cb..defb733 100644 --- a/tests/mcp_integration.rs +++ b/tests/mcp_integration.rs @@ -14,8 +14,6 @@ use serde_json::{Value, json}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, ReadHalf, WriteHalf}; struct McpClient { - /// Notifications seen while awaiting responses (see `request`). - notifications: Vec, reader: BufReader>, writer: WriteHalf, next_id: i64, @@ -49,7 +47,6 @@ impl McpClient { }); let (r, w) = tokio::io::split(client_io); let mut client = Self { - notifications: Vec::new(), reader: BufReader::new(r), writer: w, next_id: 0, @@ -98,14 +95,7 @@ impl McpClient { if value.get("id").and_then(Value::as_i64) == Some(id) { return value; } - // BUFFER, don't drop. A fast task can publish its terminal - // status before the enqueue response is even written, so a - // test that only reads AFTER the response would miss the - // notification entirely and wrongly conclude the server never - // pushes. - if value.get("id").is_none() { - self.notifications.push(value); - } + // Anything else (a notification) is skipped. } }) .await @@ -113,45 +103,6 @@ impl McpClient { } } -impl McpClient { - /// Read frames until a NOTIFICATION with `method` arrives, discarding - /// responses along the way. - /// - /// `request` deliberately SKIPS notifications, so a test written only with - /// it cannot tell a server that pushes from one that never does. - async fn wait_for_notification(&mut self, method: &str, timeout: Duration) -> Option { - // Already buffered by an earlier `request`? A fast task's push can beat - // the very response that started it. - if let Some(pos) = self - .notifications - .iter() - .position(|v| v.get("method").and_then(Value::as_str) == Some(method)) - { - return Some(self.notifications.remove(pos)); - } - tokio::time::timeout(timeout, async { - loop { - let mut buf = String::new(); - let n = self.reader.read_line(&mut buf).await.ok()?; - if n == 0 { - return None; - } - let Ok(value) = serde_json::from_str::(&buf) else { - continue; - }; - if value.get("id").is_none() - && value.get("method").and_then(Value::as_str) == Some(method) - { - return Some(value); - } - } - }) - .await - .ok() - .flatten() - } -} - /// Walk a schema JSON tree asserting no `$ref`/`$defs` keys — wire-level /// backstop for the unit test in `src/mcp.rs` (Gemini CLI, n8n, and some /// bridges reject or drop referenced schemas). @@ -262,27 +213,18 @@ async fn initialize_reports_capabilities_and_identity() { let caps = init["capabilities"] .as_object() .expect("capabilities object"); - for cap in ["tools", "prompts", "resources", "completions", "tasks"] { + for cap in ["tools", "prompts", "resources", "completions"] { assert!( caps.contains_key(cap), "missing `{cap}` capability: {caps:?}" ); } - for path in [ - &["tasks", "list"][..], - &["tasks", "cancel"][..], - &["tasks", "requests", "tools", "call"][..], - ] { - let mut value = &init["capabilities"]; - for segment in path { - value = &value[*segment]; - } - assert!( - value.is_object(), - "task capability `{}` must be advertised: {caps:?}", - path.join(".") - ); - } + // The SEP-1686 (v1) task surface is retired (0.8.0): measured unused, and + // every rmcp 3.x release removes it. + assert!( + !caps.contains_key("tasks"), + "`tasks` must not be advertised: {caps:?}" + ); assert_eq!( init["serverInfo"]["name"].as_str(), @@ -361,7 +303,6 @@ async fn message_archive_tools_expose_evidence_fields_and_safety_contracts() { let tools = resp["result"]["tools"].as_array().expect("tools array"); let source = find_tool(tools, "download_message_source"); - assert_eq!(source["execution"]["taskSupport"], json!("optional")); assert_eq!(source["annotations"]["destructiveHint"], json!(false)); let source_output = source["outputSchema"]["properties"] .as_object() @@ -385,7 +326,6 @@ async fn message_archive_tools_expose_evidence_fields_and_safety_contracts() { } let thread = find_tool(tools, "download_thread"); - assert_eq!(thread["execution"]["taskSupport"], json!("optional")); assert!( thread["description"] .as_str() @@ -638,11 +578,6 @@ async fn list_identities_is_a_bounded_read_only_tool() { assert_eq!(tool["annotations"]["readOnlyHint"], json!(true)); assert_eq!(tool["annotations"]["idempotentHint"], json!(true)); - assert!( - tool.get("execution") - .is_none_or(|execution| execution["taskSupport"].is_null()), - "a bounded read is not a task tool: {tool:#}" - ); let sent = &tool["inputSchema"]["properties"]["sentMessages"]; assert_eq!(schema_minimum(sent), Some(1.0)); assert_eq!(schema_maximum(sent), Some(1000.0)); @@ -664,10 +599,30 @@ async fn list_identities_is_a_bounded_read_only_tool() { ); } +/// No tool declares task support any more (0.8.0 retired the SEP-1686 task +/// surface), so a client is never invited to task-augment a call. +#[tokio::test] +async fn no_tool_declares_task_support() { + let mut client = McpClient::start().await; + let resp = client.request("tools/list", json!({})).await; + let tools = resp["result"]["tools"].as_array().expect("tools array"); + for tool in tools { + assert!( + tool.get("execution").is_none(), + "`{}` still declares `execution`: {tool:#}", + tool["name"] + ); + } +} + +/// A task-augmented call is REFUSED and the tool never runs. The server no +/// longer declares `tasks`, so a client MUST NOT send one; on rmcp 2.2 one +/// that does gets rmcp's default refusal (a JSON-RPC error). Had the tool run, +/// the dummy account would have produced an `isError` RESULT instead. #[tokio::test] -async fn task_result_is_repeatable_related_and_terminal_cancel_is_rejected() { +async fn a_task_augmented_call_is_refused_never_run() { let mut client = McpClient::start().await; - let queued = client + let resp = client .request( "tools/call", json!({ @@ -684,67 +639,8 @@ async fn task_result_is_repeatable_related_and_terminal_cancel_is_rejected() { ) .await; assert!( - queued.get("error").is_none(), - "task enqueue failed: {queued:#}" - ); - let task_id = queued["result"]["task"]["taskId"] - .as_str() - .expect("queued task id") - .to_string(); - - // tasks/result is the blocking result endpoint: the call must return the - // original tool result, never a transient "still running" error. - let first = client - .request("tasks/result", json!({"taskId": task_id})) - .await; - assert!( - first.get("error").is_none(), - "task result failed: {first:#}" - ); - assert_eq!(first["result"]["isError"], json!(true)); - assert_eq!( - first["result"]["_meta"]["io.modelcontextprotocol/related-task"]["taskId"], - json!(task_id) - ); - - let second = client - .request("tasks/result", json!({"taskId": task_id})) - .await; - assert_eq!( - second["result"], first["result"], - "retained task results must be repeatable" - ); - - let info = client - .request("tasks/get", json!({"taskId": task_id})) - .await; - assert_eq!(info["result"]["taskId"], json!(task_id)); - assert_eq!( - info["result"]["status"], - json!("failed"), - "an isError tool result is a failed task: {info:#}" - ); - - let cancellation = client - .request("tasks/cancel", json!({"taskId": task_id})) - .await; - assert_eq!( - cancellation["error"]["code"], - json!(-32602), - "terminal task cancellation must be invalid params: {cancellation:#}" - ); - - let listed = client.request("tasks/list", json!({})).await; - let tasks = listed["result"]["tasks"] - .as_array() - .expect("task list array"); - assert!( - tasks.iter().any(|task| task["taskId"] == task_id), - "retained task should be listed: {listed:#}" - ); - assert!( - listed["result"].get("total").is_none(), - "tasks/list must not add a non-spec total field: {listed:#}" + resp.get("error").is_some() && resp.get("result").is_none(), + "a task-augmented call must be refused, not run: {resp:#}" ); } @@ -1451,7 +1347,6 @@ async fn unsubscribe_schema_requires_identity_and_consent_with_safe_defaults() { "legacy flat flag {legacy} must not survive the cleanup object" ); } - assert_eq!(tool["execution"]["taskSupport"], json!("optional")); let missing = client .request( @@ -1861,7 +1756,6 @@ async fn domain_tools_expose_exact_hierarchy_and_action_contracts() { .is_some_and(|description| description.contains("never includes")), "`{name}` must document exact-domain scope: {input:#}" ); - assert_eq!(tool["execution"]["taskSupport"], json!("optional")); } assert_eq!( find_tool(tools, "delete_by_domain")["annotations"]["destructiveHint"], @@ -1907,7 +1801,6 @@ async fn top_subscription_move_maps_the_nested_sample_and_stays_non_destructive( "sample UIDVALIDITY must be non-zero" ); assert_eq!(tool["annotations"]["destructiveHint"], json!(false)); - assert_eq!(tool["execution"]["taskSupport"], json!("optional")); let output = tool["outputSchema"]["properties"] .as_object() @@ -1965,7 +1858,6 @@ async fn reconciliation_tools_expose_durable_operation_state() { let reconcile = find_tool(tools, "reconcile_moves"); assert_eq!(reconcile["annotations"]["destructiveHint"], json!(true)); assert_eq!(reconcile["annotations"]["idempotentHint"], json!(true)); - assert_eq!(reconcile["execution"]["taskSupport"], json!("optional")); for field in [ "examined", "completed", @@ -2599,63 +2491,3 @@ async fn check_connection_unknown_account_is_an_iserror_result() { "the error message reaches the caller: {result:#}" ); } - -/// A task-augmented call must PUSH `notifications/tasks/status` when it -/// reaches a terminal state. -/// -/// Task status is computed LAZILY here (`refresh_managed_task` runs on read), -/// so before this the transition was observable only by polling `tasks/get` — -/// a client that started a `delete_by_domain` over thousands of messages had -/// no way to be told it finished. -/// -/// Asserting on the notification specifically matters: every other task test -/// in this file uses `request`, which skips notifications, so all of them -/// passed against a server that never pushed at all. -#[tokio::test] -async fn task_terminal_transition_is_pushed_as_a_notification() { - let mut client = McpClient::start().await; - let queued = client - .request( - "tools/call", - json!({ - "name": "unsubscribe_message", - "arguments": { - "account": "dummy", - "mailbox": "INBOX", - "uid": 1, - "expectedUidValidity": 1, - "confirmOneClick": false - }, - "task": {"ttl": 60_000} - }), - ) - .await; - assert!(queued.get("error").is_none(), "enqueue failed: {queued:#}"); - let task_id = queued["result"]["task"]["taskId"] - .as_str() - .expect("task id") - .to_string(); - - let note = client - .wait_for_notification("notifications/tasks/status", Duration::from_secs(10)) - .await - .expect("server must PUSH tasks/status on the terminal transition"); - - assert_eq!(note["params"]["taskId"], task_id.as_str()); - let pushed = note["params"]["status"].as_str().unwrap_or(""); - assert!( - matches!(pushed, "completed" | "failed"), - "pushed status must be terminal, got {pushed:?}" - ); - - // The PUSHED status must agree with the POLLED one — they are produced by - // the same lazy refresh, and a disagreement would be worse than no push. - let polled = client - .request("tasks/get", json!({"taskId": task_id})) - .await; - assert_eq!( - polled["result"]["status"].as_str(), - Some(pushed), - "pushed status must match a subsequent tasks/get: {polled:#}" - ); -} From 5314afc55e049aa12a36b99aad0711457c4ebd77 Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:56:13 -0700 Subject: [PATCH 16/25] build: rmcp 3.5.1; the protocol stays MCP 2025-11-25 rmcp =2.2.0 -> =3.5.1, exact-pinned to the embedding Agent Muse workspace's copy so the app links one rmcp. The revision does not move with the SDK (rmcp 3.5.0 made LATEST and Default 2026-07-28): get_info names PROTOCOL_VERSION (2025-11-25), and supported_protocol_versions() = known_up_to(PROTOCOL_VERSION) bounds initialize, server/discover and per-request _meta. A 2026-07-28 probe is refused -32022 with that list, which a probing client reads as legacy; discover itself answers method-not-found, so a probe at a 2025 version cannot start the 2026 stateless lifecycle at 2025-11-25. Mechanical: ServerInfo -> ServerConfig; the Meta extractor -> RequestMetaObject; schema_for_output returns the schema (no Result; the object-root guard it carried is tools_list_has_36_annotated_tools); the read_resource response enum; ListToolsResult's new fields defaulted. A task-augmented tools/call now runs as a plain call (rmcp 3.x has no `task` param), the 2025-11-25 rule for a server without the capability; the test flips from a_task_augmented_call_is_refused_never_run. New tests: a_2026_07_28_client_is_negotiated_down_to_2025_11_25, server_discover_lists_only_versions_up_to_2025_11_25. --- CHANGELOG.md | 15 ++-- Cargo.lock | 33 +++++---- Cargo.toml | 9 ++- DESIGN.md | 2 +- MCP.md | 5 ++ src/mcp/mod.rs | 48 ++++++++++--- src/mcp/tools_read.rs | 46 ++++++------ src/mcp/tools_write.rs | 80 ++++++++++----------- tests/mcp_integration.rs | 147 ++++++++++++++++++++++++++++++--------- 9 files changed, 255 insertions(+), 130 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 472ac36..b960d4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **MCP 2025-11-25, by decision** — the server speaks 2025-11-25 and every + earlier revision, and nothing newer, whatever rmcp calls latest (3.5.0 made + that 2026-07-28). A client asking for 2026-07-28 is answered in 2025-11-25. + `server/discover` (2026-07-28) is refused with `-32022` listing the versions + spoken, or with method-not-found at a 2025 version, so a probing client falls + back to `initialize`. - **IMAP's TLS is rustls, with the OS checking certificates.** `native-tls` and `tokio-native-tls` are gone; connections use `tokio-rustls` on aws-lc-rs, and `rustls-platform-verifier` hands the certificate check to the OS @@ -104,8 +110,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 rebased onto 0.17.0 (nom 8) and its UIDFETCH patch re-applied; mail-auth 0.12.1 → 0.13.3; dirs 6 → 7; semver-compatible updates across the lockfile. rusqlite stays at 0.39 (the embedding app's sqlx-sqlite caps libsqlite3-sys - below 0.38) and rmcp at 2.2.0 (3.x removes the task API; its own decision in - the embedding workspace). + below 0.38). rmcp 2.2.0 → 3.5.1, exact-pinned to the embedding app's copy; + the MCP revision does not move with it (below). - **`fresh_logins` / `idle_reuses` are labelled as pool-wide** — the log lines that carry them now name them `pool_fresh_logins` and `pool_idle_reuses`, and `ConnectionStats` documents that the counts cover every account since the @@ -122,13 +128,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `notifications/tasks/status` push. Agent Muse, which embeds this server, measured the task surface unused (no task-augmented call, no `tasks/*` request in two weeks of traffic), and every rmcp 3.x release removes the - MCP 2025-11-25 task model. A task-augmented `tools/call` is refused and the - tool does not run. Long operations still report `notifications/progress` and + MCP 2025-11-25 task model. A task-augmented `tools/call` runs as a plain + call, the spec's rule for a server without the capability. Long operations still report `notifications/progress` and honour `notifications/cancelled`. Tasks may return as the SEP-2663 extension. A breaking change, hence the minor version bump. - **The `io.modelcontextprotocol/related-task` marker** on progress notifications and task results went with them. + ### Fixed - **Dead pooled sessions passed their liveness check.** async-imap's `NOOP` diff --git a/Cargo.lock b/Cargo.lock index e8aae3b..6c1adfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -868,9 +868,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ "darling_core", "darling_macro", @@ -878,26 +878,26 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -1786,6 +1786,8 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -2999,14 +3001,14 @@ dependencies = [ [[package]] name = "rmcp" -version = "2.2.0" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af" +checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350" dependencies = [ - "async-trait", - "base64 0.22.1", + "base64 0.23.1", "chrono", "futures", + "indexmap", "pastey", "pin-project-lite", "rmcp-macros", @@ -3017,19 +3019,20 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "uuid", ] [[package]] name = "rmcp-macros" -version = "2.2.0" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3" +checksum = "a1da498700030856ba63114bceefa30ba21f5e278cb975e5b9de3529fe842b40" dependencies = [ "darling", "proc-macro2", "quote", "serde_json", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8c1a674..2deaa8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,11 +55,10 @@ percent-encoding = "2" printpdf = { version = "0.12", default-features = false } psl = "2" reqwest = { version = "0.13", features = ["json"] } -# Pinned to the bridge's rmcp until the 3.x bump lands across the Agent Muse -# bridge, agentmail and eventkit-rs. The v1 task API that 3.x removes is gone -# from this server since 0.8.0; the protocol stays at MCP 2025-11-25 (the -# embedding workspace's bridge D41). -rmcp = { version = "=2.2.0", features = ["server", "macros", "transport-io"] } +# Exact-pinned to the embedding Agent Muse workspace's rmcp, so the app links +# one copy. The protocol does not move with the SDK: it stays at MCP +# 2025-11-25 (`mcp::PROTOCOL_VERSION`; the workspace's bridge D41). +rmcp = { version = "=3.5.1", features = ["server", "macros", "transport-io"] } # Held at 0.39 (libsqlite3-sys ^0.37) so embedding workspaces that also carry # sqlx-sqlite 0.9 (libsqlite3-sys >=0.30.1,<0.38) can converge on one native # sqlite — libsqlite3-sys has `links = "sqlite3"`, so exactly one version may diff --git a/DESIGN.md b/DESIGN.md index 25f2fee..2cf3217 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -4,7 +4,7 @@ Agentmail is a cross-platform IMAP email client library with an MCP (Model Context Protocol) server for AI assistant integration. It provides 36 tools and 6 prompts for reading, searching, composing, organizing, archiving, and managing email across multiple accounts. [MCP.md](MCP.md) is the authoritative wire-contract catalog. -MCP protocol: [2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25) (also negotiates 2025-06-18, 2025-03-26, and 2024-11-05) | rmcp 2.2 +MCP protocol: [2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25) (also negotiates 2025-06-18, 2025-03-26, and 2024-11-05; nothing newer) | rmcp 3.5.1 No Mail.app dependency. Pure IMAP over TLS. Works on macOS, Linux, and Windows. diff --git a/MCP.md b/MCP.md index 273d91d..eee393b 100644 --- a/MCP.md +++ b/MCP.md @@ -6,6 +6,11 @@ updated: 2026-08-23T00:00 MCP protocol: [2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25) (also negotiates 2025-06-18, 2025-03-26, and 2024-11-05) | [rmcp](https://crates.io/crates/rmcp) (official Rust MCP SDK) | Transport: stdio (standalone) or AsyncRead+AsyncWrite (in-process) +Nothing newer: a client asking for 2026-07-28 is answered in 2025-11-25, and a +2026-07-28 `server/discover` probe is refused with `-32022` listing the versions +spoken (method-not-found at a 2025 version), which tells a probing client to +fall back to `initialize`. The revision moves by decision, never with rmcp. + **Supported capabilities:** tools, prompts, resources, completions, progress notifications, request cancellation **Contract conventions:** diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index d2dd9c0..f30e758 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -15,8 +15,8 @@ use rmcp::{ handler::server::router::tool::ToolRouter, model::{ CompleteRequestParams, CompleteResult, Implementation, ListResourceTemplatesResult, - ListResourcesResult, Meta, PaginatedRequestParams, ProgressNotificationParam, - ReadResourceRequestParams, ReadResourceResult, ServerCapabilities, ServerInfo, + ListResourcesResult, PaginatedRequestParams, ProgressNotificationParam, ProtocolVersion, + ReadResourceRequestParams, RequestMetaObject, ServerCapabilities, ServerConfig, }, prompt_handler, service::RequestContext, @@ -24,6 +24,12 @@ use rmcp::{ }; use std::sync::Arc; +/// The MCP revision this server speaks: 2025-11-25, by decision rather than +/// whatever the SDK calls latest. rmcp's `Default` follows its `LATEST`, which +/// is 2026-07-28 from 3.5.0 on, a revision whose lifecycle this server has not +/// adopted (the embedding workspace's bridge D41 holds the go criteria). +const PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion::V_2025_11_25; + const PREWARM_CONCURRENCY: usize = 3; const PREWARM_ACCOUNT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); /// The session's PRIMARY workspace root, as a JSON string. @@ -118,7 +124,7 @@ fn normalized_progress( /// Build a bounded, coalescing progress stream. One worker serializes all /// notifications for the request, preserving monotonic progress and avoiding /// a detached Tokio task per callback. -fn make_progress_fn(meta: &Meta, peer: &Peer) -> ProgressReporter { +fn make_progress_fn(meta: &RequestMetaObject, peer: &Peer) -> ProgressReporter { let Some(token) = meta.get_progress_token() else { return ProgressReporter::disabled(); }; @@ -261,7 +267,7 @@ impl AgentMailServer { fn file_access_for_request( &self, - meta: &Meta, + meta: &RequestMetaObject, ) -> Result { if let Some(policy) = &self.file_access { return Ok(policy.clone()); @@ -382,10 +388,35 @@ impl ServerHandler for AgentMailServer { tools, meta: None, next_cursor: None, + ..Default::default() }) } - fn get_info(&self) -> ServerInfo { + /// Every revision up to `PROTOCOL_VERSION` and nothing newer. rmcp's + /// default is every version it knows, 2026-07-28 included, so + /// `server/discover` would advertise it and `initialize` would agree to + /// it. This one list bounds discovery, the handshake and the version a + /// request names in its own `_meta`. + fn supported_protocol_versions(&self) -> std::borrow::Cow<'static, [ProtocolVersion]> { + std::borrow::Cow::Borrowed(ProtocolVersion::known_up_to(&PROTOCOL_VERSION)) + } + + /// `server/discover` does not exist in 2025-11-25, so it is answered as a + /// 2025-11-25 server answers it. A probe at 2026-07-28 never gets here: + /// rmcp refuses it first with `-32022` and the list above, which a probing + /// client reads as "legacy, use `initialize`". A probe at a 2025 version + /// does, and rmcp's default would answer it with a discover result and let + /// the client run the 2026 stateless lifecycle at a 2025 version. + async fn discover( + &self, + _context: RequestContext, + ) -> Result { + Err(McpError::method_not_found::< + rmcp::model::DiscoverRequestMethod, + >()) + } + + fn get_info(&self) -> ServerConfig { // No `tasks`: the SEP-1686 (MCP 2025-11-25) task surface was retired, // measured unused — every rmcp 3.x release removes it (0.8.0). let capabilities = ServerCapabilities::builder() @@ -394,7 +425,8 @@ impl ServerHandler for AgentMailServer { .enable_resources() .enable_completions() .build(); - ServerInfo::new(capabilities) + ServerConfig::new(capabilities) + .with_protocol_version(PROTOCOL_VERSION) // Without this the server announces itself as "rmcp" — rmcp's // Implementation::from_build_env() bakes in its own crate name. // Version carries the build SHA so `initialize` responses and logs @@ -627,8 +659,8 @@ impl ServerHandler for AgentMailServer { &self, request: ReadResourceRequestParams, _context: RequestContext, - ) -> Result { - self.read_email_resource(&request.uri).await + ) -> Result { + self.read_email_resource(&request.uri).await.map(Into::into) } async fn complete( diff --git a/src/mcp/tools_read.rs b/src/mcp/tools_read.rs index c1c0291..8d54e47 100644 --- a/src/mcp/tools_read.rs +++ b/src/mcp/tools_read.rs @@ -12,7 +12,7 @@ use super::{Pagination, bounded_usize, make_cancel_fn, make_progress_fn}; use rmcp::{ ErrorData as McpError, Peer, RoleServer, handler::server::wrapper::Parameters, - model::{CallToolResult, Meta}, + model::{CallToolResult, RequestMetaObject}, tool, tool_router, }; use tokio_util::sync::CancellationToken; @@ -34,7 +34,7 @@ fn parse_search_date(s: Option<&str>) -> Result, McpEr impl AgentMailServer { #[tool( name = "list_accounts", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid list_accounts output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Return configured IMAP account names, each with whether it is the default, its display name, and the addresses it sends as (primary first) — the addresses a draft's `from` may use. The names themselves are already in every tool's `account` enum and are listed as email://{account} resources — call this when the DEFAULT or the sending addresses matter, not to discover a selector.", annotations( title = "List Accounts", @@ -54,7 +54,7 @@ impl AgentMailServer { #[tool( name = "list_identities", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid list_identities output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "List the addresses one account sends as: its configured addresses (configured=true — the only ones a draft's `from` may use, the primary first) and the From addresses its recent Sent mail actually used, each with a message count, the display names it went out under, and when it was last used. Reads the newest sentMessages (default 200, max 1000) messages of the Sent mailbox with BODY.PEEK, so nothing is marked read; sentMailbox is null when the account has none, and the configured addresses are still listed. An address seen in Sent but not configured cannot be used as `from` until it is added to the account as an alias.", annotations( title = "List Sender Identities", @@ -88,7 +88,7 @@ impl AgentMailServer { #[tool( name = "list_mailboxes", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid list_mailboxes output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "List selectable mailboxes for one required account. Returns a page of mailbox names, total and unseen counts, hierarchy delimiters, no-inferiors state, and all recognized special-use roles. Non-selectable containers are omitted. Defaults: offset=0, limit=100 (max 500).", annotations(title = "List Mailboxes", read_only_hint = true) )] @@ -118,7 +118,7 @@ impl AgentMailServer { #[tool( name = "check_connection", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid check_connection output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Test IMAP connectivity for an account. Connects, authenticates, and reports the outcome as data: connected=true, or connected=false with the error text — a probe against a configured account never raises a protocol error. An unknown account raises invalid params.", annotations(title = "Check Connection", read_only_hint = true) )] @@ -134,7 +134,7 @@ impl AgentMailServer { #[tool( name = "list_capabilities", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid list_capabilities output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "List the IMAP extensions this account's server advertises. Several change how other tools behave: REPLACE (RFC 8508) lets update_draft swap a draft atomically instead of emulating it, UIDPLUS allows a targeted UID EXPUNGE, MOVE avoids COPY-then-delete reconciliation, APPENDLIMIT caps a single draft, SPECIAL-USE lets Drafts and Trash be found by role rather than by name, and UIDONLY/MESSAGELIMIT put the account in RFC 9586 UID Mode.", annotations(title = "List IMAP Capabilities", read_only_hint = true) )] @@ -150,7 +150,7 @@ impl AgentMailServer { #[tool( name = "get_messages", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid get_messages output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Fetch a metadata-only page of messages from one required mailbox, newest-first. Returns account, mailbox, UIDVALIDITY, pagination data (offset, limit, total, nextOffset), and compact rows with UID, subject, sender, date, flags, and size. Every row's message rides the result as a resource_link — the markdown body, plus that message's /info for exact headers, bounded raw RFC822 source, and the attachment inventory. Follow those links; the result carries no URI in its JSON and building one by hand is unsupported. Get mailbox names from list_mailboxes. Defaults: offset=0, limit=25 (max 50).", annotations(title = "Get Messages", read_only_hint = true) )] @@ -175,7 +175,7 @@ impl AgentMailServer { #[tool( name = "search_messages", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid search_messages output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Search one required mailbox with filters: senderContains, subjectContains, toContains, query (IMAP full-text), read, flagged, headerKey/headerValueContains, since/before (YYYY-MM-DD), and largerThan/smallerThan (bytes). Filters are AND-combined. Returns metadata-only results newest-first with the mailbox UIDVALIDITY and pagination data (offset, limit, total, nextOffset). Every row's message rides the result as a resource_link — the markdown body, plus that message's /info for headers, source, and attachments. Follow those links; the result carries no URI in its JSON. Get mailbox names from list_mailboxes. Defaults: offset=0, limit=25 (max 50).", annotations(title = "Search Messages", read_only_hint = true) )] @@ -227,13 +227,13 @@ impl AgentMailServer { #[tool( name = "list_flags", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid list_flags output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "List all IMAP flags in use with counts per flag (e.g. \\Seen: 1234, \\Flagged: 56). Omit mailbox for account-wide discovery: one selectable \\All mailbox is preferred, otherwise selectable storage mailboxes are enumerated without Trash/Junk/Drafts or virtual aggregate views. Resolves Apple Mail $MailFlagBit color flags to color names (red, orange, yellow, green, blue, purple, gray). An account-wide scan lists in skipped the mailboxes it did not cover — one it couldn't open, or every mailbox after a lost connection; their messages are in none of the counts.", annotations(title = "List Flags", read_only_hint = true) )] async fn list_flags_tool( &self, - meta: Meta, + meta: RequestMetaObject, client: Peer, ct: CancellationToken, Parameters(args): Parameters, @@ -258,13 +258,13 @@ impl AgentMailServer { #[tool( name = "find_attachments", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid find_attachments output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Find messages with attachments (multipart/mixed or multipart/related), newest-first. Each hit includes mailbox, UIDVALIDITY, UID, and date so account-wide UID collisions are unambiguous, and rides the result as a resource_link. Omit mailbox for account-wide discovery: one selectable \\All mailbox is preferred, otherwise selectable storage mailboxes are enumerated without excluded or virtual views. Defaults: offset=0, limit=25 (max 100). To save files, pass a hit's mailbox, uid, and uidValidity as expectedUidValidity to download_attachments. An account-wide scan lists in skipped the mailboxes it did not cover; their messages are not in total.", annotations(title = "Find Attachments", read_only_hint = true) )] async fn find_attachments_tool( &self, - meta: Meta, + meta: RequestMetaObject, client: Peer, ct: CancellationToken, Parameters(args): Parameters, @@ -293,13 +293,13 @@ impl AgentMailServer { #[tool( name = "top_senders", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid top_senders output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "List the senders who email you most, by message count. Omit mailbox for account-wide discovery: one selectable \\All mailbox is scanned alone when available; otherwise selectable storage mailboxes are enumerated, virtual/excluded views are skipped, and Message-ID deduplicates across folders. Excludes your own address and groups by exact address + display name. Every row has a safe nested sample {mailbox, uidValidity, uid} for inspection, linked from the result as a resource_link; pass its exact address and displayName to delete_by_sender or move_by_sender. Live offset pagination defaults to 10 rows (max 100).", annotations(title = "Top Senders", read_only_hint = true) )] async fn top_senders_tool( &self, - meta: Meta, + meta: RequestMetaObject, client: Peer, ct: CancellationToken, Parameters(args): Parameters, @@ -328,13 +328,13 @@ impl AgentMailServer { #[tool( name = "top_domains", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid top_domains output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "List exact canonical Header From domains by message count. A parent and each subdomain are separate rows: example.com never includes mail.example.com. Each row supplies registrableDomain and subdomain as Public Suffix List context, plus a UIDVALIDITY-safe sample and its decoded subject when available. Header From is organizational metadata, not proof of DKIM ownership. Omit mailbox for account-wide discovery. Live offset pagination defaults to 20 rows (max 100); use the exact domain value with delete_by_domain or move_by_domain.", annotations(title = "Top Domains", read_only_hint = true) )] async fn top_domains_tool( &self, - meta: Meta, + meta: RequestMetaObject, client: Peer, ct: CancellationToken, Parameters(args): Parameters, @@ -363,13 +363,13 @@ impl AgentMailServer { #[tool( name = "top_subscriptions", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid top_subscriptions output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "List bulk/marketing subscriptions by message count. Omit mailbox for account-wide discovery, which prefers one selectable \\All mailbox and otherwise enumerates storage mailboxes. Rows are grouped only by normalized sender email; display names and List-Id values do not split a sender's row. Rows are sorted by advertised one-click syntax, then count. Each has a nested sample {mailbox, uidValidity, uid}, linked from the result as a resource_link, and, when available, the sample message's decoded subject — what this subscription's mail actually looks like. Map the sample to move_subscription to file exact bulk-mail matches without an unsubscribe request, or to unsubscribe_message for a consented verified unsubscribe. advertisedOneClick is syntactic only—the unsubscribe action re-fetches the complete message and verifies DKIM. Live offset pagination defaults to 10 rows (max 100).", annotations(title = "Top Subscriptions", read_only_hint = true) )] async fn top_subscriptions_tool( &self, - meta: Meta, + meta: RequestMetaObject, client: Peer, ct: CancellationToken, Parameters(args): Parameters, @@ -398,13 +398,13 @@ impl AgentMailServer { #[tool( name = "top_mailing_lists", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid top_mailing_lists output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "List mailing lists by normalized List-Id (RFC 2919), highest volume first, including List-Id-only messages and grouping across senders. Each row includes a bounded sender preview, senderCount, a UIDVALIDITY-safe nested sample for inspection, and, when available, the sample message's decoded subject — what this list's mail actually looks like. Omit mailbox for account-wide discovery, which prefers one selectable \\All mailbox and otherwise enumerates storage mailboxes. Live offset pagination defaults to 10 rows (max 100). Use delete_list_id or move_list_id with an approved exact listId.", annotations(title = "Top Mailing Lists", read_only_hint = true) )] async fn top_mailing_lists_tool( &self, - meta: Meta, + meta: RequestMetaObject, client: Peer, ct: CancellationToken, Parameters(args): Parameters, @@ -433,7 +433,7 @@ impl AgentMailServer { #[tool( name = "list_pending_moves", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid list_pending_moves output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "List durable non-native IMAP MOVE operations that still need automatic reconciliation or human attention. Each row includes operationId, the source UID identity, destination, status, detail, and timestamps. Use reconcile_moves with one operationId, or omit it to reconcile all pending operations for the account.", annotations( title = "List Pending Moves", @@ -453,7 +453,7 @@ impl AgentMailServer { #[tool( name = "preview_thread_record", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid preview_thread_record output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Preview the exact, bounded cross-mailbox RFC Message-ID graph around one live message. Requires mailbox, uid, and expectedUidValidity from the same discovery result. Selection uses only exact Message-ID, In-Reply-To, and References relationships—never subject similarity—and returns every exact storage identity plus a confirmation digest. No files are written.", annotations( title = "Preview Thread Record", @@ -463,7 +463,7 @@ impl AgentMailServer { )] async fn preview_thread_record_tool( &self, - meta: Meta, + meta: RequestMetaObject, client: Peer, ct: CancellationToken, Parameters(args): Parameters, diff --git a/src/mcp/tools_write.rs b/src/mcp/tools_write.rs index 5fe4d0b..8c3afa8 100644 --- a/src/mcp/tools_write.rs +++ b/src/mcp/tools_write.rs @@ -18,7 +18,7 @@ use crate::{ use rmcp::{ ErrorData as McpError, Peer, RoleServer, handler::server::wrapper::Parameters, - model::{CallToolResult, Meta}, + model::{CallToolResult, RequestMetaObject}, tool, tool_router, }; use tokio::io::AsyncReadExt as _; @@ -456,7 +456,7 @@ fn body_format(plain_text_only: bool) -> crate::draft::BodyFormat { /// only attaching to one is. async fn load_draft_attachments( server: &AgentMailServer, - meta: &Meta, + meta: &RequestMetaObject, attachments: &[DraftAttachmentArg], ) -> Result, McpError> { if attachments.is_empty() { @@ -594,7 +594,7 @@ async fn load_draft_attachments( impl AgentMailServer { #[tool( name = "create_mailbox", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid create_mailbox output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Create a mailbox on the IMAP server. For a nested mailbox, use the hierarchy delimiter reported by list_mailboxes (commonly '/').", annotations( title = "Create Mailbox", @@ -622,7 +622,7 @@ impl AgentMailServer { #[tool( name = "rename_mailbox", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid rename_mailbox output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Preview or confirm a guarded mailbox rename. The first call returns live message count, special-use roles, descendants, and exact confirmations. The confirmed call requires expectedMessageCount from that preview, refuses INBOX and pending MOVE journals, and re-lists after an ambiguous transport outcome.", annotations( title = "Rename Mailbox", @@ -661,7 +661,7 @@ impl AgentMailServer { #[tool( name = "delete_mailbox", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid delete_mailbox output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Preview or confirm guarded mailbox deletion. The first call returns live message count, special-use roles, descendants, and required confirmations. A confirmed delete requires the exact preview count plus separate acknowledgements for non-empty, special-use, or descendant-bearing mailboxes. INBOX and mailboxes referenced by pending MOVE journals are always blocked; an already-missing mailbox is an idempotent success.", annotations( title = "Delete Mailbox", @@ -697,7 +697,7 @@ impl AgentMailServer { #[tool( name = "delete_messages", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid delete_messages output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Delete one or more messages by UID. Requires expectedUidValidity from the same mailbox discovery response and fails before mutation if the UID epoch changed. Moves to Trash when available; otherwise permanent fallback follows the requested policy. Supports 1 to 500 non-zero UIDs per call.", annotations( title = "Delete Messages", @@ -707,7 +707,7 @@ impl AgentMailServer { )] async fn delete_messages_tool( &self, - meta: Meta, + meta: RequestMetaObject, client: Peer, ct: CancellationToken, Parameters(args): Parameters, @@ -751,13 +751,13 @@ impl AgentMailServer { #[tool( name = "delete_by_sender", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid delete_by_sender output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Delete all messages from an exact sender identity. Pass the address and displayName exactly as returned by a top_senders row; matching is exact on both fields, confirmed live before any deletion. Omit mailbox to enumerate selectable storage mailboxes; mutation planning excludes Trash/Junk/Drafts and never writes through \\All, \\Flagged, or \\Important aggregate views. Do not use this for mailing-list cleanup (use delete_list_id or unsubscribe_message cleanup).", annotations(title = "Delete by Sender", destructive_hint = true) )] async fn delete_by_sender_tool( &self, - meta: Meta, + meta: RequestMetaObject, client: Peer, ct: CancellationToken, Parameters(args): Parameters, @@ -789,7 +789,7 @@ impl AgentMailServer { #[tool( name = "delete_by_domain", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid delete_by_domain output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Delete every message whose first parsed Header From address has one exact canonical domain. Pass domain exactly as returned by top_domains: example.com never includes mail.example.com or any other subdomain. Omit mailbox to sweep the account-wide mutation plan, which excludes Trash/Junk/Drafts and aggregate views. With permanent=false, Trash is preferred but permanent fallback may be used when Trash is unavailable; permanent=true bypasses Trash. Header From matching is organizational cleanup, not DKIM authentication.", annotations( title = "Delete by Exact Domain", @@ -799,7 +799,7 @@ impl AgentMailServer { )] async fn delete_by_domain_tool( &self, - meta: Meta, + meta: RequestMetaObject, client: Peer, ct: CancellationToken, Parameters(args): Parameters, @@ -830,7 +830,7 @@ impl AgentMailServer { #[tool( name = "download_attachments", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid download_attachments output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Download all attachments from one message to disk. Pass mailbox, uid, and expectedUidValidity from the same find_attachments hit; the download fails before filesystem writes if the mailbox UID epoch changed. Each file is saved as {uid}_{index}_{name} with the name sanitized (and shortened, extension kept, when it is too long for a filesystem) — the same canonical filename /info reports for that part. Omit outputDir to write into the session workspace. Returns absolute paths, content types, and sizes. All or nothing: a name that already exists is refused before anything is written, and a failure partway removes the files already written.", annotations( title = "Download Attachments", @@ -841,7 +841,7 @@ impl AgentMailServer { )] async fn download_attachments_tool( &self, - meta: Meta, + meta: RequestMetaObject, Parameters(args): Parameters, ) -> Result { if args.mailbox.trim().is_empty() { @@ -878,7 +878,7 @@ impl AgentMailServer { #[tool( name = "download_message_source", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid download_message_source output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Save one exact RFC822 message source directly from IMAP to disk without passing the bytes through model context. Requires mailbox, uid, and expectedUidValidity from one discovery result. Uses BODY.PEEK[] so the message is not marked read, refuses files above 64 MiB, confines outputDir to the active session workspace (standalone server: AGENTMAIL_FILE_ROOT), and never overwrites. Returns the absolute path, byte count, SHA-256, Message-ID/date/from/subject metadata, and a contemporaneous local DKIM verification against DNS. SPF is omitted because it cannot be independently recomputed from an archived RFC822 message without SMTP connection and envelope data.", annotations( title = "Download Message Source", @@ -889,7 +889,7 @@ impl AgentMailServer { )] async fn download_message_source_tool( &self, - meta: Meta, + meta: RequestMetaObject, ct: CancellationToken, Parameters(args): Parameters, ) -> Result { @@ -923,7 +923,7 @@ impl AgentMailServer { #[tool( name = "download_thread", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid download_thread output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Save a caller-supplied set of one to 100 exact RFC822 sources from the same mailbox UIDVALIDITY epoch directly to disk as {uid}.eml, then write a JSON evidence manifest. This tool does not discover thread membership; pass the UIDs selected by prior discovery. Bytes never pass through model context. Every source uses BODY.PEEK[], is capped at 64 MiB, receives SHA-256 plus parsed envelope metadata and local DNS-backed DKIM verification, and is created without overwrite inside the active session workspace (standalone server: AGENTMAIL_FILE_ROOT). SPF is omitted because an archived message lacks the SMTP inputs required for independent verification. Returns the manifest path and its complete message entries.", annotations( title = "Download Message Thread", @@ -934,7 +934,7 @@ impl AgentMailServer { )] async fn download_thread_tool( &self, - meta: Meta, + meta: RequestMetaObject, client: Peer, ct: CancellationToken, Parameters(args): Parameters, @@ -1044,7 +1044,7 @@ impl AgentMailServer { #[tool( name = "export_thread_record", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid export_thread_record output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "After preview_thread_record, export that exact confirmed selection into the active session workspace as a private bundle containing a styled, page-numbered PDF; one immutable RFC822 .eml source per selected storage identity; and a JSON integrity manifest. Re-discovers the graph and refuses selectionDigest drift, never overwrites, caps each source at 64 MiB and the bundle at 512 MiB, verifies DKIM against DNS, then reopens/parses/hash-checks every artifact before returning recorded=true and submittable=true. Those flags describe packet completeness, not legal admissibility. This tool never sends or mutates mail.", annotations( title = "Export Verified Thread Record", @@ -1056,7 +1056,7 @@ impl AgentMailServer { )] async fn export_thread_record_tool( &self, - meta: Meta, + meta: RequestMetaObject, client: Peer, ct: CancellationToken, Parameters(args): Parameters, @@ -1093,7 +1093,7 @@ impl AgentMailServer { #[tool( name = "create_draft", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid create_draft output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Create and save a draft — a fresh message, or a reply to a live one. Resolves the account's selectable \\Drafts special-use mailbox, falls back to Drafts and creates it when needed, then APPENDs the message with the \\Draft flag. Compose fresh by giving recipients: at least one of to, cc or bcc. Reply instead by giving replyToMessage {mailbox, uid, expectedUidValidity, mode} from a discovery result — that derives the recipients (Reply-To before From, excluding this account's own addresses and aliases), a Re: subject you may override, and the RFC In-Reply-To/References headers; to, cc, inReplyTo and references must then be omitted, and bcc is still never inferred. Subject and body are optional; attachments may reference local file paths. The body is read as Markdown and sent as multipart/alternative — the text exactly as written, plus an HTML rendering — so **bold**, lists, links and tables arrive formatted rather than as literal syntax. Raw HTML in the body is escaped, never rendered. Set plainTextOnly=true for a single unrendered text/plain part. The draft is From the account's primary address under its display name; pass `from` (Name
or an address) to use another of the account's own addresses — list_accounts lists them, and any other address is refused. A reply is From the address the original was sent to unless `from` says otherwise. Returns the sender actually written as `from`, plus the new draft's uid and uidValidity when the server allows recovering them, and links the draft as a resource_link. A draft's UID is NOT durable — re-saving it (here, or in any other mail client) appends a new message and expunges the old one, and some servers discard an APPENDed draft outright. Re-read the link from a fresh get_messages rather than reusing one from an earlier turn. This tool never sends mail.", annotations( title = "Create Draft", @@ -1103,7 +1103,7 @@ impl AgentMailServer { )] async fn create_draft_tool( &self, - meta: Meta, + meta: RequestMetaObject, Parameters(args): Parameters, ) -> Result { let loaded = load_draft_attachments(self, &meta, &args.attachments).await?; @@ -1188,7 +1188,7 @@ impl AgentMailServer { #[tool( name = "update_draft", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid update_draft output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Replace one live IMAP draft with a complete new draft specification. Requires mailbox, uid, and expectedUidValidity; verifies the target still has the \\Draft flag. Uses RFC 8508 REPLACE where the server has it and otherwise emulates it, so this succeeds on servers without REPLACE (Gmail and iCloud among them) — do not hand-roll create_draft + delete_messages. Returns the NEW uid and uidValidity: a replaced draft always has a new identity, so discard the old one. A `warning` field appears only if the superseded draft could not be removed. Attachments are a complete replacement list, and this tool never sends mail. The draft keeps its current sender when that is one of the account's addresses (so one chosen in another mail client survives); `from` replaces it with another of the account's own addresses, and the result's `from` is the sender written. The body is read as Markdown and sent as multipart/alternative (the text as written plus an HTML rendering); raw HTML in it is escaped, never rendered. Set plainTextOnly=true for a single unrendered text/plain part.", annotations( title = "Update Draft Atomically", @@ -1199,7 +1199,7 @@ impl AgentMailServer { )] async fn update_draft_tool( &self, - meta: Meta, + meta: RequestMetaObject, Parameters(args): Parameters, ) -> Result { if args.mailbox.trim().is_empty() { @@ -1240,7 +1240,7 @@ impl AgentMailServer { #[tool( name = "move_message", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid move_message output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Move one message between mailboxes. Requires source mailbox, destination, uid, and expectedUidValidity from the message's discovery response; the action fails before mutation if the source UID epoch changed. Success confirms source-to-destination completion but does not claim a destination UID.", annotations( title = "Move Message", @@ -1277,7 +1277,7 @@ impl AgentMailServer { #[tool( name = "unsubscribe_message", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid unsubscribe_message output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Perform a live-validated RFC 8058 one-click unsubscribe and optionally delete matching messages account-wide. Map a top_subscriptions row's nested sample to mailbox, uid, and expectedUidValidity, and require explicit confirmOneClick=true. The POST requires exact headers, local DKIM verification covering both action headers, one public HTTPS destination, no redirects, and a direct 2xx. Omit cleanup to only unsubscribe. When cleanup is present it deletes by the DKIM-authenticated List-Id, or (identity \"listIdOrSender\", the default) falls back to exact sender email + List-Unsubscribe-Post + the target's List-Id when it has one; cleanup.when gates running after a failed attempt and cleanup.deletion controls Trash versus permanent disposal.", annotations( title = "Unsubscribe from Mailing List", @@ -1287,7 +1287,7 @@ impl AgentMailServer { )] async fn unsubscribe_message_tool( &self, - meta: Meta, + meta: RequestMetaObject, client: Peer, ct: CancellationToken, Parameters(args): Parameters, @@ -1322,13 +1322,13 @@ impl AgentMailServer { #[tool( name = "delete_list_id", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid delete_list_id output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Delete all messages with a specific List-Id. Identifies the list by its exact List-Id value from top_mailing_lists and deletes matching messages regardless of sender. Omit mailbox to enumerate selectable storage mailboxes; mutation planning excludes Trash/Junk/Drafts and never writes through \\All, \\Flagged, or \\Important aggregate views.", annotations(title = "Delete Mailing List by List-Id", destructive_hint = true) )] async fn delete_list_id_tool( &self, - meta: Meta, + meta: RequestMetaObject, client: Peer, ct: CancellationToken, Parameters(args): Parameters, @@ -1355,7 +1355,7 @@ impl AgentMailServer { #[tool( name = "move_list_id", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid move_list_id output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Move all messages with a specific List-Id to a destination mailbox in one operation — e.g. archive a newsletter or statement list without per-message calls. Identifies the list by its exact List-Id value from top_mailing_lists. The destination must already exist (create_mailbox first if needed). Omit mailbox to sweep selectable storage mailboxes account-wide; the destination itself is always excluded, and mutation planning excludes Trash/Junk/Drafts and aggregate views.", annotations( title = "Move Mailing List by List-Id", @@ -1365,7 +1365,7 @@ impl AgentMailServer { )] async fn move_list_id_tool( &self, - meta: Meta, + meta: RequestMetaObject, client: Peer, ct: CancellationToken, Parameters(args): Parameters, @@ -1395,7 +1395,7 @@ impl AgentMailServer { #[tool( name = "move_by_sender", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid move_by_sender output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Move all messages from an exact sender identity to a destination mailbox in one operation — e.g. file monthly statements from a bank into a folder. Pass the address and displayName exactly as returned by a top_senders row; matching is exact on both fields, confirmed live before any move. The destination must already exist. Omit mailbox to sweep selectable storage mailboxes account-wide; the destination itself is always excluded.", annotations( title = "Move by Sender", @@ -1405,7 +1405,7 @@ impl AgentMailServer { )] async fn move_by_sender_tool( &self, - meta: Meta, + meta: RequestMetaObject, client: Peer, ct: CancellationToken, Parameters(args): Parameters, @@ -1439,7 +1439,7 @@ impl AgentMailServer { #[tool( name = "move_by_domain", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid move_by_domain output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Move every message whose first parsed Header From address has one exact canonical domain to an existing destination mailbox. Pass domain exactly as returned by top_domains: example.com never includes mail.example.com or any other subdomain. Omit mailbox to sweep selectable storage mailboxes account-wide; the destination itself is excluded. Header From matching is organizational cleanup, not DKIM authentication.", annotations( title = "Move by Exact Domain", @@ -1450,7 +1450,7 @@ impl AgentMailServer { )] async fn move_by_domain_tool( &self, - meta: Meta, + meta: RequestMetaObject, client: Peer, ct: CancellationToken, Parameters(args): Parameters, @@ -1483,7 +1483,7 @@ impl AgentMailServer { #[tool( name = "move_subscription", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid move_subscription output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Move the exact bulk-mail subscription represented by one top_subscriptions row to an existing destination mailbox. Map the row's nested sample to mailbox, expectedUidValidity, and uid; the action re-fetches that sample live, derives its canonical sender email and single List-Id when present, then sweeps the account-wide mutation plan. Every moved message must have the exact sender plus List-Unsubscribe or List-Unsubscribe-Post, and must also have the same List-Id when the sample has one. The destination is excluded. This files mail only; it does not send an unsubscribe request.", annotations( title = "Move Top Subscription", @@ -1493,7 +1493,7 @@ impl AgentMailServer { )] async fn move_subscription_tool( &self, - meta: Meta, + meta: RequestMetaObject, client: Peer, ct: CancellationToken, Parameters(args): Parameters, @@ -1527,7 +1527,7 @@ impl AgentMailServer { #[tool( name = "reconcile_moves", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid reconcile_moves output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Safely reconcile durable non-native IMAP MOVE operations after a connection loss or ambiguous COPY/delete boundary. Pass one operationId from list_pending_moves, or omit it to process all pending operations for the account. A source message is removed only right after its destination copy has been seen, and COPY is retried only when the destination's UIDNEXT proves the first attempt created nothing; cases it can't prove stay needsAttention instead of risking loss or a duplicate. needsAttention operations are examined again on every call. An attempt that learns nothing (a dropped connection, a timeout) leaves its operation as it was and is listed in errors. To close a move that still can't finish, check both mailboxes, then pass its operationId with dismiss: true — that moves and deletes nothing.", annotations( title = "Reconcile Pending Moves", @@ -1537,7 +1537,7 @@ impl AgentMailServer { )] async fn reconcile_moves_tool( &self, - meta: Meta, + meta: RequestMetaObject, client: Peer, ct: CancellationToken, Parameters(args): Parameters, @@ -1588,7 +1588,7 @@ impl AgentMailServer { #[tool( name = "update_flags", - output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid update_flags output schema"), + output_schema = rmcp::handler::server::tool::schema_for_output::(), description = "Add flags, remove flags, and set or clear the Apple Mail color on one message — in a single call. Requires mailbox, uid, and expectedUidValidity; fails before any change if the mailbox UID epoch moved. Adding and removing in ONE call is the point: doing them as two calls opens two epoch windows and can leave the first applied and the second refused. Order is remove, then color, then add, so a flag named in both lists ends up SET. Unnamed flags are never touched. Cannot set or remove \\Deleted (use delete_messages) or \\Recent (read-only). Returns the message's complete resulting flag set.", annotations( title = "Update Flags / Color", @@ -1722,7 +1722,7 @@ mod tests { crate::Agentmail::builder(crate::Config::empty()).build(), ); // An EMPTY meta is exactly a session that never got a workspace root. - let meta = rmcp::model::Meta::new(); + let meta = rmcp::model::RequestMetaObject::default(); let loaded = super::load_draft_attachments(&server, &meta, &[]) .await diff --git a/tests/mcp_integration.rs b/tests/mcp_integration.rs index defb733..657ebbd 100644 --- a/tests/mcp_integration.rs +++ b/tests/mcp_integration.rs @@ -22,36 +22,11 @@ struct McpClient { impl McpClient { async fn start() -> Self { - let config = Config::from_accounts(vec![( - "dummy".to_string(), - AccountConfig { - host: "imap.invalid".to_string(), - port: 993, - username: "dummy@example.invalid".to_string(), - email: None, - aliases: Vec::new(), - display_name: None, - password: Some(Secret::new_raw("unused")), - tls: true, - max_connections: None, - auth: agentmail::AuthMethod::Password, - }, - )]); - Self::start_with(config).await + Self::start_with(dummy_config()).await } async fn start_with(config: Config) -> Self { - let (client_io, server_io) = tokio::io::duplex(1024 * 1024); - tokio::spawn(async move { - let _ = agentmail::mcp::serve_on(server_io, Agentmail::new(config)).await; - }); - let (r, w) = tokio::io::split(client_io); - let mut client = Self { - reader: BufReader::new(r), - writer: w, - next_id: 0, - init_result: Value::Null, - }; + let mut client = Self::connect(config).await; let resp = client .request( "initialize", @@ -67,6 +42,22 @@ impl McpClient { client } + /// A running server and nothing sent yet, for a test that chooses its + /// own first frame. + async fn connect(config: Config) -> Self { + let (client_io, server_io) = tokio::io::duplex(1024 * 1024); + tokio::spawn(async move { + let _ = agentmail::mcp::serve_on(server_io, Agentmail::new(config)).await; + }); + let (r, w) = tokio::io::split(client_io); + Self { + reader: BufReader::new(r), + writer: w, + next_id: 0, + init_result: Value::Null, + } + } + async fn notify(&mut self, method: &str, params: Value) { let mut line = serde_json::to_string(&json!({"jsonrpc": "2.0", "method": method, "params": params})) @@ -615,12 +606,14 @@ async fn no_tool_declares_task_support() { } } -/// A task-augmented call is REFUSED and the tool never runs. The server no -/// longer declares `tasks`, so a client MUST NOT send one; on rmcp 2.2 one -/// that does gets rmcp's default refusal (a JSON-RPC error). Had the tool run, -/// the dummy account would have produced an `isError` RESULT instead. +/// A task-augmented call RUNS as a plain call. The server declares no +/// `tasks`, and the 2025-11-25 spec says a receiver without the capability +/// "MUST process requests of that type normally, ignoring any +/// task-augmentation metadata"; rmcp 3.x does so by construction (its call +/// params have no `task` field). rmcp 2.2 refused it with a JSON-RPC error. +/// The dummy account cannot connect, so the run shows as an `isError` RESULT. #[tokio::test] -async fn a_task_augmented_call_is_refused_never_run() { +async fn a_task_augmented_call_runs_as_a_plain_call() { let mut client = McpClient::start().await; let resp = client .request( @@ -639,9 +632,95 @@ async fn a_task_augmented_call_is_refused_never_run() { ) .await; assert!( - resp.get("error").is_some() && resp.get("result").is_none(), - "a task-augmented call must be refused, not run: {resp:#}" + resp.get("error").is_none() && resp["result"]["isError"] == json!(true), + "a task-augmented call must run as a plain call: {resp:#}" ); + assert!( + resp["result"].get("task").is_none(), + "the answer is a CallToolResult, not a task handle: {resp:#}" + ); +} + +/// One account that is never connected: only config-backed tools and +/// validation paths run, so no network is touched. +fn dummy_config() -> Config { + Config::from_accounts(vec![( + "dummy".to_string(), + AccountConfig { + host: "imap.invalid".to_string(), + port: 993, + username: "dummy@example.invalid".to_string(), + email: None, + aliases: Vec::new(), + display_name: None, + password: Some(Secret::new_raw("unused")), + tls: true, + max_connections: None, + auth: agentmail::AuthMethod::Password, + }, + )]) +} + +/// The probe a 2026-07-28 client opens with: `server/discover` carrying its +/// version, identity and capabilities in `_meta`. +fn discover_probe(version: &str) -> Value { + json!({"_meta": { + "io.modelcontextprotocol/protocolVersion": version, + "io.modelcontextprotocol/clientInfo": {"name": "agentmail-tests", "version": "0"}, + "io.modelcontextprotocol/clientCapabilities": {}, + }}) +} + +/// An `initialize` asking for 2026-07-28 is answered in 2025-11-25, the +/// revision this server speaks, rather than agreed to because rmcp knows it. +#[tokio::test] +async fn a_2026_07_28_client_is_negotiated_down_to_2025_11_25() { + let mut client = McpClient::connect(dummy_config()).await; + let resp = client + .request( + "initialize", + json!({ + "protocolVersion": "2026-07-28", + "capabilities": {}, + "clientInfo": {"name": "agentmail-tests", "version": "0"} + }), + ) + .await; + assert_eq!(resp["result"]["protocolVersion"], "2025-11-25", "{resp:#}"); +} + +/// A 2026-07-28 probe is refused `-32022` with ONLY the versions this server +/// speaks, which a probing client reads as "legacy, use `initialize`". A probe +/// at a 2025 version is told the method does not exist. Then the legacy +/// handshake still works on the same connection, which is the fallback. +#[tokio::test] +async fn server_discover_lists_only_versions_up_to_2025_11_25() { + let mut client = McpClient::connect(dummy_config()).await; + let resp = client + .request("server/discover", discover_probe("2026-07-28")) + .await; + assert_eq!(resp["error"]["code"], -32022, "{resp:#}"); + assert_eq!( + resp["error"]["data"]["supported"], + json!(["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"]), + ); + + let resp = client + .request("server/discover", discover_probe("2025-11-25")) + .await; + assert_eq!(resp["error"]["code"], -32601, "{resp:#}"); + + let resp = client + .request( + "initialize", + json!({ + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "agentmail-tests", "version": "0"} + }), + ) + .await; + assert_eq!(resp["result"]["protocolVersion"], "2025-11-25", "{resp:#}"); } #[tokio::test] From be381a567a7cdaaf121635c933333ff27cb91afd Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:12:35 -0700 Subject: [PATCH 17/25] feat: MCP 2026-07-28 alongside 2025-11-25, with SEP-2663 tasks; 0.9.0 Both lifecycles from one server. supported_protocol_versions() is known_up_to(MAX_PROTOCOL_VERSION), named as 2026-07-28 in the source, never rmcp's LATEST. initialize answers LATEST_WITH_INITIALIZE (2025-11-25), also to a client asking for 2026-07-28. server/discover is answered only at a revision without a handshake and lists only those (2026-07-28); at a 2025 version it is method-not-found, so no client runs the stateless lifecycle at a revision that defines none. A 2025 client sees what 0.8.0 sent, plus the tasks extension. Tasks return as the io.modelcontextprotocol/tasks extension, offered on both revisions and promoted late (src/mcp/tasks.rs). A call becomes a task only when: - the client declared the extension, - the tool is one of the 22 long-running ones (0.8.0's task list), - it is still running after 2 seconds, - and admission allows it (32 running, 1,024 held). Everything else is answered inline exactly as before. Each task lives 4 hours and suggests 1-second polls. - The operation runs on a child of the connection's lifetime token, never the request's: rmcp cancels a request's token as it sends the response, and a task's response is its handle (the SEP-1686 self-cancel bug). - tasks/cancel is cooperative. A task whose tool saw it through its CancelFn is cancelled; one that finished first is completed. A panic fails the task instead of leaving it working until the TTL. - After promotion, progress is the task's status message plus _meta["io.agentmuse/progress"] on tasks/get. - Closing the connection cancels every operation, waits 5 seconds, then aborts what is left. Library: the ten mutations that take a CancelFn leave the per-account queue when it fires (lock_account_mutation_unless_cancelled), instead of when the run ahead of them ends. Tests: the runtime (inline under budget, outliving the request token, cancel before and after promotion, finishing despite a cancel, panic before and after, TTL abort, progress, admission, shutdown), a promoted task polled to its result over the JSON-RPC wire, discover at 2026 and 2025, an unknown revision, tasks/* without the extension, and an unknown task id. Docs: MCP.md (two lifecycles; Tasks), README, DESIGN, DECISIONS (two 0.9.0 entries), CHANGELOG. --- CHANGELOG.md | 44 ++- Cargo.lock | 2 +- Cargo.toml | 6 +- DECISIONS.md | 109 ++++++ DESIGN.md | 4 +- MCP.md | 73 +++- README.md | 9 +- src/lib.rs | 80 +++- src/mcp/mod.rs | 366 +++++++++++++++--- src/mcp/tasks.rs | 790 +++++++++++++++++++++++++++++++++++++++ tests/mcp_integration.rs | 150 ++++++-- 11 files changed, 1514 insertions(+), 119 deletions(-) create mode 100644 src/mcp/tasks.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b960d4c..a6ac292 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ --- created: 2026-05-29T19:20 -updated: 2026-10-13T00:00 +updated: 2026-10-14T00:00 --- # Changelog @@ -13,6 +13,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **MCP tasks, as the SEP-2663 extension** (`io.modelcontextprotocol/tasks`, + offered on both revisions) — a call to one of 22 long-running tools (the + scans, bulk moves and deletes, unsubscribing, downloads and thread exports) + from a client that declared the extension becomes a task when it is still + running after 2 seconds: the client gets a task handle at once and polls + `tasks/get`. Quick calls, and every call from a client that did not declare + the extension, are answered inline as before. Up to 32 tasks run at once and + 1,024 are held, past which a slow call simply stays a request; a task lives + 4 hours. `tasks/cancel` stops the operation at its next check, never + mid-write: a task that saw the cancellation is `cancelled`, one that finished + first is `completed`, and a tool that panics is `failed`. Progress becomes + the task's status message, and `tasks/get` carries the numbers under + `_meta["io.agentmuse/progress"]`. Each operation runs on the connection's own + token, never its request's, which rmcp cancels as it answers the request. + Closing the connection stops its tasks (5 seconds' grace, then abort). - **Sender identity** — `display_name` in an account's config makes drafts `From: Mark Blake `. `create_draft` and `update_draft` take an optional `from` (`Name
` or an address) that must be one of the @@ -48,12 +63,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **MCP 2025-11-25, by decision** — the server speaks 2025-11-25 and every - earlier revision, and nothing newer, whatever rmcp calls latest (3.5.0 made - that 2026-07-28). A client asking for 2026-07-28 is answered in 2025-11-25. - `server/discover` (2026-07-28) is refused with `-32022` listing the versions - spoken, or with method-not-found at a 2025 version, so a probing client falls - back to `initialize`. +- **MCP 2026-07-28 alongside 2025-11-25, version 0.9.0** — a 2026-07-28 + client runs that revision's stateless lifecycle: `server/discover` answers + with the revisions that need no handshake (2026-07-28 only), every request + carries its own version, identity and capabilities in `_meta`, and results + carry `resultType` and cache hints. A 2025-era client still opens with + `initialize`, answered in 2025-11-25 exactly as before, and a client asking + `initialize` for 2026-07-28 is answered in 2025-11-25 too. `server/discover` + at a 2025 version is method-not-found, so no client runs the stateless + lifecycle at a revision that defines none, and a revision newer than + 2026-07-28 is refused `-32022` with the list. The ceiling is named + (`MAX_PROTOCOL_VERSION`), never rmcp's `LATEST`, so a newer SDK cannot move + the protocol on its own. +- **A mutation queued behind another on its account stops waiting when + cancelled.** The ten mutations that take a `CancelFn` (the bulk moves and + deletes, `delete_messages`, `reconcile_moves`, `unsubscribe_message`) leave + the per-account queue with `Cancelled` as soon as it fires, instead of when + the run ahead of them ends. - **IMAP's TLS is rustls, with the OS checking certificates.** `native-tls` and `tokio-native-tls` are gone; connections use `tokio-rustls` on aws-lc-rs, and `rustls-platform-verifier` hands the certificate check to the OS @@ -130,8 +156,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 request in two weeks of traffic), and every rmcp 3.x release removes the MCP 2025-11-25 task model. A task-augmented `tools/call` runs as a plain call, the spec's rule for a server without the capability. Long operations still report `notifications/progress` and - honour `notifications/cancelled`. Tasks may return as the SEP-2663 - extension. A breaking change, hence the minor version bump. + honour `notifications/cancelled`. Tasks returned in 0.9.0 as the SEP-2663 + extension (see Added). A breaking change, hence the minor version bump. - **The `io.modelcontextprotocol/related-task` marker** on progress notifications and task results went with them. diff --git a/Cargo.lock b/Cargo.lock index 6c1adfb..22a5b27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agentmail" -version = "0.8.0" +version = "0.9.0" dependencies = [ "apple-native-keyring-store", "async-imap", diff --git a/Cargo.toml b/Cargo.toml index 2deaa8e..26b5f17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ imap-proto = { path = "vendor/imap-proto" } [package] name = "agentmail" -version = "0.8.0" +version = "0.9.0" edition = "2024" rust-version = "1.99" description = "IMAP email client, MCP server, and CLI — read, search, delete, unsubscribe, and manage mailboxes" @@ -56,8 +56,8 @@ printpdf = { version = "0.12", default-features = false } psl = "2" reqwest = { version = "0.13", features = ["json"] } # Exact-pinned to the embedding Agent Muse workspace's rmcp, so the app links -# one copy. The protocol does not move with the SDK: it stays at MCP -# 2025-11-25 (`mcp::PROTOCOL_VERSION`; the workspace's bridge D41). +# one copy. The protocol does not move with the SDK: the server speaks up to +# `mcp::MAX_PROTOCOL_VERSION` (2026-07-28), named in the source. rmcp = { version = "=3.5.1", features = ["server", "macros", "transport-io"] } # Held at 0.39 (libsqlite3-sys ^0.37) so embedding workspaces that also carry # sqlx-sqlite 0.9 (libsqlite3-sys >=0.30.1,<0.38) can converge on one native diff --git a/DECISIONS.md b/DECISIONS.md index 0f15714..0be37c4 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -4,6 +4,115 @@ Architectural decisions, deferred work, and rationale for future reference. --- +## 0.9.0 — Both Lifecycles From One Server, Up To A Named Ceiling + +### Decision + +The server speaks MCP 2026-07-28 and every earlier revision. +`supported_protocol_versions()` is `ProtocolVersion::known_up_to(&MAX_PROTOCOL_VERSION)`, +with `MAX_PROTOCOL_VERSION` = 2026-07-28 written in the source. The +`initialize` answer is `LATEST_WITH_INITIALIZE` (2025-11-25), which rmcp also +gives a client that asks `initialize` for 2026-07-28. `server/discover` is +answered only at a revision with no handshake, and lists only those (today +2026-07-28 alone). At a 2025 version it is method-not-found. + +### Rationale + +0.8.0 pinned 2025-11-25 because nothing yet needed 2026-07-28, and the +embedding app's bridge held the go criteria. Agent Muse now offers 2026-07-28 +to agents and backends as a choice. A server that supports both lifecycles +costs nothing for a 2025 client, so this server offers both. + +The ceiling stays named, never rmcp's `LATEST`: an SDK bump must not move the +protocol on its own. + +The discover gate closes a trap in rmcp's default, which answers a probe at +any supported version. A client probing at 2025-11-25 would then run the +stateless lifecycle at a revision that defines none. Listing only the +handshake-free revisions in `supportedVersions` closes the same door from the +other side. + +### Consequence + +A 2026-07-28 client carries its version, identity and capabilities in every +request's `_meta`. rmcp validates them and shapes results for that revision: +it adds `resultType`, defaults cache hints to `private`, TTL 0, and turns +resource-not-found into `-32602`. A 2025 client sees exactly what 0.8.0 sent, +plus the tasks extension in `capabilities.extensions`. + +--- + +## 0.9.0 — Tasks Return As The SEP-2663 Extension, Promoted Late + +### Decision + +`io.modelcontextprotocol/tasks` is offered on both revisions (`mcp/tasks.rs`). +A `tools/call` becomes a task only when four things hold: + +- the client declared the extension; +- the tool is one of the 22 long-running ones (`TASK_TOOLS`, the 0.8.0 list + of task-eligible tools); +- the call is still running after 2 seconds; +- admission allows it: 32 running, 1,024 held. + +Otherwise it is answered inline. Each task lives 4 hours and suggests +1-second polls. + +**The operation token.** It is a child of the connection's lifetime token, +linked to the request's token only until promotion. + +**Cancellation.** `tasks/cancel` cancels that token and waits for the +operation to stop at its next check. A task whose tool saw the cancellation +(through its `CancelFn`) settles `cancelled`; one that finished first settles +`completed`. + +**Progress.** After promotion, progress goes to the task's status message +and to `_meta["io.agentmuse/progress"]` on `tasks/get`. + +### Rationale + +**Promoted late.** SEP-2663 makes the server the sole decider. The tasks a +client actually wants are the calls that would outlive its request timeout. +A task costs the client a round trip and keeps a result for hours. Deciding +after a short run makes only those calls tasks. Quick calls, which are nearly +all calls, see no difference. + +**The token is not the request's.** rmcp cancels a request's token as it +sends that request's response, and a task's response is its handle. The +SEP-1686 implementation retired in 0.8.0 had exactly this self-cancel bug +until its token was re-parented. Here the rule is structural, and pinned by +`a_promoted_task_outlives_its_request_token`. + +**Cooperative, never aborted.** A bulk move cut at an arbitrary await could +leave a COPY without its EXPUNGE. The library already stops cleanly at its +`CancelFn` checks, and now also while queued behind another mutation on its +account (`lock_account_mutation_unless_cancelled`). Only rmcp's TTL aborts, +and nothing here runs for 4 hours. + +**`cancelled` follows what the tool did.** Most tools report a library error +as an `isError` result, so the result alone cannot tell a cancelled scan from +a failed one. `make_cancel_fn` records the check that found the token +cancelled, which can. + +**Status, not notifications, after promotion.** The request is answered once +the handle is sent. A `notifications/progress` on its token would describe a +request that no longer exists. + +### Consequence + +The operation runs in its own spawned task, so a panic fails the task instead +of leaving it `working` until the TTL. rmcp's completion path never runs for +a future that unwinds. + +Tasks live with the connection. Closing it cancels every operation token, +waits 5 seconds, then aborts what is left. + +The per-account destructive-task queue of 0.8.0's predecessor does not +return. The library's account mutation lock already serializes every +mutation. + +--- + ## 0.7.0 — A Draft Is From One Of The Account's Own Identities ### Decision diff --git a/DESIGN.md b/DESIGN.md index 2cf3217..f3ddd2e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -4,7 +4,7 @@ Agentmail is a cross-platform IMAP email client library with an MCP (Model Context Protocol) server for AI assistant integration. It provides 36 tools and 6 prompts for reading, searching, composing, organizing, archiving, and managing email across multiple accounts. [MCP.md](MCP.md) is the authoritative wire-contract catalog. -MCP protocol: [2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25) (also negotiates 2025-06-18, 2025-03-26, and 2024-11-05; nothing newer) | rmcp 3.5.1 +MCP protocol: [2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28) (stateless) and [2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25) (`initialize`; also negotiates 2025-06-18, 2025-03-26, and 2024-11-05) | rmcp 3.5.1 No Mail.app dependency. Pure IMAP over TLS. Works on macOS, Linux, and Windows. @@ -268,7 +268,7 @@ Email content flows through a pipeline: - **Mailbox attributes** — RFC 6154 special-use roles (trash, junk, drafts, sent, archive, all, flagged) and RFC 3501 flags (noSelect, noInferiors) surfaced from IMAP LIST. Scan-all operations use roles to skip Trash/Junk/Drafts with string-matching fallback for servers without RFC 6154 support - **Tool annotations** — `read_only_hint`, `destructive_hint`, `idempotent_hint` per MCP 2025-11-25 - **Progress notifications** — long operations report progress to the MCP client -- **No MCP tasks** — the SEP-1686 task surface (enqueue, poll, result, cancel, per-account serialization of destructive tasks) was retired in 0.8.0, measured unused; long operations report progress and honour `notifications/cancelled` +- **MCP tasks, promoted late** — the SEP-2663 extension: a call from a client that declared it becomes a task only when it is still running after 2 seconds (`mcp/tasks.rs`), so quick calls stay quick. Each operation runs on the connection's lifetime token, because rmcp cancels a request's token as it answers the request, and a task's answer is its handle. Cancellation is cooperative, and the account mutation lock is the one serialization point (a queued mutation leaves the queue when cancelled). The SEP-1686 surface retired in 0.8.0 stays gone - **Forward recovery for MOVE** — servers without native UID MOVE use a separate `synchronous=FULL` journal. Ambiguous COPY/cleanup outcomes return durable operation IDs and explicit pending/attention states; reconciliation never repeats COPY without evidence that the earlier attempt created nothing ## Configuration and Local Security diff --git a/MCP.md b/MCP.md index eee393b..c20cd83 100644 --- a/MCP.md +++ b/MCP.md @@ -1,17 +1,22 @@ --- created: 2026-05-29T19:20 -updated: 2026-08-23T00:00 +updated: 2026-10-14T00:00 --- # Agentmail MCP — Tool & Prompt Reference -MCP protocol: [2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25) (also negotiates 2025-06-18, 2025-03-26, and 2024-11-05) | [rmcp](https://crates.io/crates/rmcp) (official Rust MCP SDK) | Transport: stdio (standalone) or AsyncRead+AsyncWrite (in-process) +MCP protocol: [2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28) and [2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25) (also negotiates 2025-06-18, 2025-03-26, and 2024-11-05) | [rmcp](https://crates.io/crates/rmcp) (official Rust MCP SDK) | Transport: stdio (standalone) or AsyncRead+AsyncWrite (in-process) -Nothing newer: a client asking for 2026-07-28 is answered in 2025-11-25, and a -2026-07-28 `server/discover` probe is refused with `-32022` listing the versions -spoken (method-not-found at a 2025 version), which tells a probing client to -fall back to `initialize`. The revision moves by decision, never with rmcp. +**Two lifecycles, one server.** A 2025-era client opens with `initialize`, +answered in 2025-11-25 (also when it asks for 2026-07-28, which has no +handshake). A 2026-07-28 client runs that revision's stateless lifecycle: +`server/discover` answers with the revisions that need no handshake +(2026-07-28 only), and every request carries its version, identity and +capabilities in `_meta`. `server/discover` at a 2025 version is +method-not-found, and a revision newer than 2026-07-28 is refused `-32022` with +the supported list. The ceiling is `MAX_PROTOCOL_VERSION`, named in the source: +the revision moves by decision, never with rmcp. -**Supported capabilities:** tools, prompts, resources, completions, progress notifications, request cancellation +**Supported capabilities:** tools, prompts, resources, completions, progress notifications, request cancellation, and the `io.modelcontextprotocol/tasks` extension (see [Tasks](#tasks-sep-2663)) **Contract conventions:** @@ -880,17 +885,49 @@ Returns the full updated flag set after the operation. | 5 | `unsubscribe-cleanup` | Rank subscriptions; offer filing or verified unsubscribe | `account` | | 6 | `list-id-cleanup` | Identify mailing lists by List-Id, bulk-delete | `account` | -## Tasks (SEP-1686) — retired in 0.8.0 - -AgentMail no longer offers MCP tasks: no `tasks` capability, no -`execution.taskSupport` on any tool, no `tasks/*` methods. The task surface was -measured unused by the app that embeds this server, and every rmcp 3.x release -removes the MCP 2025-11-25 task model; tasks may return as the SEP-2663 -extension. Long operations report `notifications/progress`, and a direct call -is cancelled with `notifications/cancelled`: scans stop at mailbox/fetch-chunk -boundaries and unsubscribe DNS, DKIM and HTTP waits are interrupted through a -25 ms cancellation poll. Cancellation during an HTTP send is inherently -ambiguous because the endpoint may already have received the POST. +## Tasks (SEP-2663) + +AgentMail offers the `io.modelcontextprotocol/tasks` extension on both +revisions. The server decides which calls become tasks, never the client: a +call becomes one only when the client declared the extension, the tool is one +of the 22 below, and the call is still running after **2 seconds**. Everything +else, quick calls included, is answered inline exactly as without the +extension. A promoted call is answered with a task handle (`resultType: +"task"`, the `taskId`, `pollIntervalMs` 1000, `ttlMs` 4 hours) and the client +polls `tasks/get`. + +Task-eligible tools: `search_messages`, `list_flags`, `find_attachments`, +`top_senders`, `top_domains`, `top_subscriptions`, `top_mailing_lists`, +`preview_thread_record`, `delete_messages`, `delete_by_sender`, +`delete_by_domain`, `delete_list_id`, `move_by_sender`, `move_by_domain`, +`move_list_id`, `move_subscription`, `unsubscribe_message`, `reconcile_moves`, +`download_attachments`, `download_message_source`, `download_thread`, +`export_thread_record`. + +| | | +| --- | --- | +| Admission | at most 32 tasks running and 1,024 held (running and finished); past either, a slow call stays a request | +| Terminal status | `completed` with the tool's result (an `isError` result is still `completed`); `failed` when the tool produced no result (a protocol error, or a panic); `cancelled` when the tool saw a `tasks/cancel` | +| `tasks/cancel` | stops the operation at its next check, never mid-write; a task that finished first is `completed` | +| Progress | the task's `statusMessage` (`"3 of 10"`), and `_meta["io.agentmuse/progress"] = {"progress", "total"}` on `tasks/get` | +| `tasks/update` | accepted for any held task; no tool asks for input mid-task | +| Lifetime | 4 hours from creation, after which rmcp fails a task still running; a finished task is kept 4 more hours. Tasks live with the connection: closing it stops them (5 seconds' grace, then abort) | + +A request's cancellation token is cancelled by rmcp as it answers the request, +and a task's answer is its handle, so each task's operation runs on the +connection's own token instead. + +Before a call is promoted, and for every call that never is, long operations +report `notifications/progress`, and a direct call is cancelled with +`notifications/cancelled`: scans stop at mailbox/fetch-chunk boundaries, +unsubscribe DNS, DKIM and HTTP waits are interrupted through a 25 ms +cancellation poll, and a mutation queued behind another on its account stops +waiting. Cancellation during an HTTP send is inherently ambiguous because the +endpoint may already have received the POST. + +The SEP-1686 surface retired in 0.8.0 stays gone: no `capabilities.tasks`, no +`execution.taskSupport`, no `tasks/list` or `tasks/result`, and a client's +`task` parameter on `tools/call` is ignored. ## Tool Result Encoding diff --git a/README.md b/README.md index 820011a..0458f8a 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ --- created: 2026-05-29T19:20 -updated: 2026-08-23T00:00 +updated: 2026-10-14T00:00 --- # agentmail IMAP email client exposed as both a CLI and an MCP (Model Context Protocol) server, built with Rust. -MCP protocol: [2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25) (also negotiates 2025-06-18, 2025-03-26, and 2024-11-05) | [rmcp](https://crates.io/crates/rmcp) (official Rust MCP SDK) +MCP protocol: [2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28) and [2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25) (also negotiates 2025-06-18, 2025-03-26, and 2024-11-05) | [rmcp](https://crates.io/crates/rmcp) (official Rust MCP SDK) One binary: `agentmail serve` starts the MCP stdio server, all other subcommands are a direct CLI. @@ -443,8 +443,9 @@ Opens a web UI to exercise all advertised tools and 6 prompts interactively. 36 tools cover account discovery, mailbox management, message reading, search, bulk operations, recovery, evidence archiving, flag management, and composition. AgentMail saves drafts but never sends mail. Long-running tools -report progress notifications. (MCP task-based invocation, SEP-1686, was -retired in 0.8.0.) +report progress notifications, and for a client that declared the MCP tasks +extension (SEP-2663) a slow call becomes a task it polls; see +[MCP.md](MCP.md#tasks-sep-2663). | Tool | Description | | ---------------------- | ------------------------------------------------------------------------------------- | diff --git a/src/lib.rs b/src/lib.rs index 0bc4719..5dcc5a8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,9 @@ const MAX_MAILBOX_PAGE_LIMIT: usize = 500; const MAX_SUBSCRIPTION_SAMPLE_HEADER_BYTES: usize = 256 * 1024; const MAX_THREAD_RECORD_MESSAGES: usize = 100; const MAX_THREAD_RECORD_HEADER_BYTES: usize = 256 * 1024; +/// How often a mutation queued behind another on its account re-checks its +/// `CancelFn` ([`Agentmail::lock_account_mutation_unless_cancelled`]). +const MUTATION_QUEUE_CANCEL_POLL: std::time::Duration = std::time::Duration::from_millis(100); /// High-level facade for IMAP operations. /// Owns the connection pool and configuration. @@ -445,6 +448,41 @@ impl Agentmail { lock.lock_owned().await } + /// [`Self::lock_account_mutation`] for a mutation that can be cancelled: + /// the wait in the queue ends with [`AgentmailError::Cancelled`] as soon + /// as `cancel` fires. + /// + /// A mutation queued behind another on the same account can wait a long + /// time, since a bulk move holds the lock for its whole run. A caller that + /// gives up while queued (a `tasks/cancel`, a `notifications/cancelled`) + /// should stop then, not when the run ahead of it ends. `cancel` is a + /// predicate rather than a future, so the wait checks it on a short tick. + async fn lock_account_mutation_unless_cancelled( + &self, + account: &str, + cancel: Option<&CancelFn>, + ) -> Result> { + imap_client::check_cancel(cancel)?; + let Some(cancel) = cancel else { + return Ok(self.lock_account_mutation(account).await); + }; + let lock = self.lock_account_mutation(account); + tokio::pin!(lock); + let mut tick = tokio::time::interval(MUTATION_QUEUE_CANCEL_POLL); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tokio::select! { + biased; + guard = &mut lock => return Ok(guard), + _ = tick.tick() => { + if cancel() { + return Err(AgentmailError::Cancelled); + } + } + } + } + } + /// Resolve server capabilities for an account, using the pool's cache and /// acquiring a session only on a cold miss. async fn caps_for(&self, account: &str) -> Result> { @@ -2710,7 +2748,9 @@ impl Agentmail { on_progress: Option<&ProgressFn>, cancel: Option<&CancelFn>, ) -> Result { - let _mutation_guard = self.lock_account_mutation(account).await; + let _mutation_guard = self + .lock_account_mutation_unless_cancelled(account, cancel) + .await?; let mut session = self.pool.acquire(account).await?; let caps = self.pool.server_caps(account, session.session()).await?; let trash = self @@ -3153,7 +3193,9 @@ impl Agentmail { cancel: Option<&CancelFn>, ) -> Result { Self::validate_uid_selector(mailbox, expected_uid_validity, uids)?; - let _mutation_guard = self.lock_account_mutation(account).await; + let _mutation_guard = self + .lock_account_mutation_unless_cancelled(account, cancel) + .await?; let mut session = self.pool.acquire(account).await?; let caps = self.pool.server_caps(account, session.session()).await?; imap_client::select_with_expected_uid_validity( @@ -3244,7 +3286,9 @@ impl Agentmail { )); } let email = parser::canonical_sender_address(email); - let _mutation_guard = self.lock_account_mutation(account).await; + let _mutation_guard = self + .lock_account_mutation_unless_cancelled(account, cancel) + .await?; let mut session = self.pool.acquire(account).await?; let caps = self.pool.server_caps(account, session.session()).await?; let trash = self @@ -3322,7 +3366,9 @@ impl Agentmail { let domain = domain::canonicalize_domain(domain).ok_or_else(|| { AgentmailError::Other("domain must be a valid DNS domain name".to_string()) })?; - let _mutation_guard = self.lock_account_mutation(account).await; + let _mutation_guard = self + .lock_account_mutation_unless_cancelled(account, cancel) + .await?; let mut session = self.pool.acquire(account).await?; let caps = self.pool.server_caps(account, session.session()).await?; let trash = self @@ -3464,7 +3510,9 @@ impl Agentmail { on_progress: Option<&ProgressFn>, cancel: Option<&CancelFn>, ) -> Result { - let _mutation_guard = self.lock_account_mutation(account).await; + let _mutation_guard = self + .lock_account_mutation_unless_cancelled(account, cancel) + .await?; let totals = self .move_sweep( mailbox, @@ -3515,7 +3563,9 @@ impl Agentmail { )); } let email = parser::canonical_sender_address(email); - let _mutation_guard = self.lock_account_mutation(account).await; + let _mutation_guard = self + .lock_account_mutation_unless_cancelled(account, cancel) + .await?; let sender_display = if name.is_empty() { email.clone() } else { @@ -3564,7 +3614,9 @@ impl Agentmail { let domain = domain::canonicalize_domain(domain).ok_or_else(|| { AgentmailError::Other("domain must be a valid DNS domain name".to_string()) })?; - let _mutation_guard = self.lock_account_mutation(account).await; + let _mutation_guard = self + .lock_account_mutation_unless_cancelled(account, cancel) + .await?; let totals = self .move_sweep( mailbox, @@ -3612,8 +3664,9 @@ impl Agentmail { if destination.trim().is_empty() { return Err(AgentmailError::Other("destination is required".to_string())); } - imap_client::check_cancel(cancel)?; - let _mutation_guard = self.lock_account_mutation(account).await; + let _mutation_guard = self + .lock_account_mutation_unless_cancelled(account, cancel) + .await?; let mut session = self.pool.acquire(account).await?; let sample_headers = match imap_client::get_message_headers_bounded( @@ -3726,7 +3779,9 @@ impl Agentmail { on_progress: Option<&ProgressFn>, cancel: Option<&CancelFn>, ) -> Result { - let _mutation_guard = self.lock_account_mutation(account).await; + let _mutation_guard = self + .lock_account_mutation_unless_cancelled(account, cancel) + .await?; let account_key = self.mutation_account_key(account)?; let operations = if let Some(operation_id) = operation_id { vec![ @@ -5032,8 +5087,9 @@ impl Agentmail { if uid == 0 { return Err(AgentmailError::MessageNotFound(0)); } - imap_client::check_cancel(cancel)?; - let _mutation_guard = self.lock_account_mutation(account).await; + let _mutation_guard = self + .lock_account_mutation_unless_cancelled(account, cancel) + .await?; // Bind the numeric UID to the exact epoch returned by discovery, then // fetch the complete message transiently for local DKIM verification. diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index f30e758..8cb9268 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -5,6 +5,7 @@ mod args; mod file_access; mod prompts; mod resources; +mod tasks; mod tools_read; mod tools_write; mod wire; @@ -12,11 +13,13 @@ mod wire; use futures::StreamExt as _; use rmcp::{ ErrorData as McpError, Peer, RoleServer, ServerHandler, ServiceExt, - handler::server::router::tool::ToolRouter, + handler::server::{router::tool::ToolRouter, tool::ToolCallContext}, model::{ - CompleteRequestParams, CompleteResult, Implementation, ListResourceTemplatesResult, - ListResourcesResult, PaginatedRequestParams, ProgressNotificationParam, ProtocolVersion, - ReadResourceRequestParams, RequestMetaObject, ServerCapabilities, ServerConfig, + CallToolRequestParams, CallToolResponse, CancelTaskParams, CompleteRequestParams, + CompleteResult, DiscoverRequestMethod, DiscoverResult, GetTaskParams, GetTaskResult, + Implementation, ListResourceTemplatesResult, ListResourcesResult, PaginatedRequestParams, + ProgressNotificationParam, ProtocolVersion, ReadResourceRequestParams, RequestMetaObject, + ServerCapabilities, ServerConfig, UpdateTaskParams, }, prompt_handler, service::RequestContext, @@ -24,11 +27,13 @@ use rmcp::{ }; use std::sync::Arc; -/// The MCP revision this server speaks: 2025-11-25, by decision rather than -/// whatever the SDK calls latest. rmcp's `Default` follows its `LATEST`, which -/// is 2026-07-28 from 3.5.0 on, a revision whose lifecycle this server has not -/// adopted (the embedding workspace's bridge D41 holds the go criteria). -const PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion::V_2025_11_25; +/// The newest MCP revision this server speaks. A 2025-era client still opens +/// with `initialize`, answered in [`ProtocolVersion::LATEST_WITH_INITIALIZE`] +/// (2025-11-25); a 2026-07-28 client runs that revision's stateless lifecycle, +/// carrying its version, identity and capabilities in every request's `_meta`. +/// Named rather than taken from rmcp's `LATEST`, so a newer SDK does not move +/// the protocol on its own. +const MAX_PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion::V_2026_07_28; const PREWARM_CONCURRENCY: usize = 3; const PREWARM_ACCOUNT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); @@ -124,38 +129,56 @@ fn normalized_progress( /// Build a bounded, coalescing progress stream. One worker serializes all /// notifications for the request, preserving monotonic progress and avoiding /// a detached Tokio task per callback. +/// +/// A call that became a task ([`tasks`]) reports on the task instead: its +/// request has been answered, so a `notifications/progress` on that request's +/// token would describe a request that no longer exists. fn make_progress_fn(meta: &RequestMetaObject, peer: &Peer) -> ProgressReporter { - let Some(token) = meta.get_progress_token() else { + let run = tasks::ToolRun::current(); + let token = meta.get_progress_token(); + if token.is_none() && run.is_none() { return ProgressReporter::disabled(); - }; - let peer = peer.clone(); + } let (sender, mut receiver) = tokio::sync::watch::channel(None::); let latest = Arc::new(parking_lot::Mutex::new(None::)); let callback_latest = Arc::clone(&latest); + let callback_run = run.clone(); let callback = Arc::new(move |completed: u64, total: u64| { let mut previous = callback_latest.lock(); let Some(update) = normalized_progress(*previous, completed, total) else { return; }; *previous = Some(update); + if callback_run + .as_ref() + .is_some_and(|run| run.report_progress(update.completed, update.total)) + { + return; + } sender.send_replace(Some(update)); }); - let worker = tokio::spawn(async move { - while receiver.changed().await.is_ok() { - let Some(update) = *receiver.borrow_and_update() else { - continue; - }; - let notification = - ProgressNotificationParam::new(token.clone(), update.completed as f64) - .with_total(update.total as f64); - if peer.notify_progress(notification).await.is_err() { - break; + let worker = token.map(|token| { + let peer = peer.clone(); + tokio::spawn(async move { + while receiver.changed().await.is_ok() { + let Some(update) = *receiver.borrow_and_update() else { + continue; + }; + if run.as_ref().is_some_and(|run| run.is_promoted()) { + break; + } + let notification = + ProgressNotificationParam::new(token.clone(), update.completed as f64) + .with_total(update.total as f64); + if peer.notify_progress(notification).await.is_err() { + break; + } } - } + }) }); ProgressReporter { callback: Some(callback), - worker: Some(worker), + worker, } } @@ -163,8 +186,19 @@ fn make_progress_fn(meta: &RequestMetaObject, peer: &Peer) -> Progre /// client sends `notifications/cancelled` for this request, or on transport /// shutdown (the token is a child of the serve-loop token). Long scans check /// it at mailbox and fetch-chunk boundaries. +/// +/// In a call that may become a task the token is the operation's own (see +/// [`tasks`]), and a check that finds it cancelled is recorded, so a task the +/// tool stopped for `tasks/cancel` settles as `cancelled`. fn make_cancel_fn(ct: tokio_util::sync::CancellationToken) -> crate::CancelFn { - Arc::new(move || ct.is_cancelled()) + let run = tasks::ToolRun::current(); + Arc::new(move || { + let cancelled = ct.is_cancelled(); + if cancelled && let Some(run) = &run { + run.note_cancel_observed(); + } + cancelled + }) } /// Map a library error to an MCP error code. Model-actionable failures @@ -248,6 +282,8 @@ pub struct AgentMailServer { /// Sandbox for LLM-supplied filesystem paths (attachment reads, download /// writes). See [`file_access::FileAccessPolicy`]. file_access: Option, + /// This connection's tasks (SEP-2663). See [`tasks`]. + tasks: Arc, } impl AgentMailServer { @@ -255,13 +291,19 @@ impl AgentMailServer { Self { agentmail: Arc::new(agentmail), file_access: Some(file_access::FileAccessPolicy::from_env()), + tasks: Arc::new(tasks::TaskRuntime::new(tasks::TaskConfig::DEFAULT)), } } fn new_embedded(agentmail: crate::Agentmail) -> Self { + Self::new_embedded_with(agentmail, tasks::TaskConfig::DEFAULT) + } + + fn new_embedded_with(agentmail: crate::Agentmail, tasks: tasks::TaskConfig) -> Self { Self { agentmail: Arc::new(agentmail), file_access: None, + tasks: Arc::new(tasks::TaskRuntime::new(tasks)), } } @@ -392,41 +434,112 @@ impl ServerHandler for AgentMailServer { }) } - /// Every revision up to `PROTOCOL_VERSION` and nothing newer. rmcp's - /// default is every version it knows, 2026-07-28 included, so - /// `server/discover` would advertise it and `initialize` would agree to - /// it. This one list bounds discovery, the handshake and the version a + /// A call to one of `tasks::TASK_TOOLS` from a client that declared the + /// tasks extension may become a task once it has run for a while (see + /// `tasks`). Every other call is dispatched exactly as the generated + /// handler did. + async fn call_tool( + &self, + request: CallToolRequestParams, + mut context: RequestContext, + ) -> Result { + let tasks_declared = context + .client_capabilities() + .is_some_and(|capabilities| capabilities.supports_tasks()); + if !(tasks_declared && tasks::TASK_TOOLS.contains(&request.name.as_ref())) { + return TOOL_ROUTER + .call(ToolCallContext::new(self, request, context)) + .await; + } + let operation_ct = self.tasks.operation_token(); + let request_ct = std::mem::replace(&mut context.ct, operation_ct.clone()); + let tool = request.name.clone(); + let server = self.clone(); + self.tasks + .run_call(&tool, request_ct, operation_ct, async move { + TOOL_ROUTER + .call(ToolCallContext::new(&server, request, context)) + .await + }) + .await + } + + async fn get_task( + &self, + request: GetTaskParams, + _context: RequestContext, + ) -> Result { + self.tasks.get(&request.task_id) + } + + async fn update_task( + &self, + request: UpdateTaskParams, + _context: RequestContext, + ) -> Result<(), McpError> { + self.tasks.update(&request.task_id, request.input_responses) + } + + async fn cancel_task( + &self, + request: CancelTaskParams, + _context: RequestContext, + ) -> Result<(), McpError> { + self.tasks.cancel(&request.task_id) + } + + /// Every revision up to `MAX_PROTOCOL_VERSION` and nothing newer, so a + /// newer rmcp cannot widen what this server claims. This one list bounds + /// `server/discover`, the `initialize` negotiation and the version a /// request names in its own `_meta`. fn supported_protocol_versions(&self) -> std::borrow::Cow<'static, [ProtocolVersion]> { - std::borrow::Cow::Borrowed(ProtocolVersion::known_up_to(&PROTOCOL_VERSION)) + std::borrow::Cow::Borrowed(ProtocolVersion::known_up_to(&MAX_PROTOCOL_VERSION)) } - /// `server/discover` does not exist in 2025-11-25, so it is answered as a - /// 2025-11-25 server answers it. A probe at 2026-07-28 never gets here: - /// rmcp refuses it first with `-32022` and the list above, which a probing - /// client reads as "legacy, use `initialize`". A probe at a 2025 version - /// does, and rmcp's default would answer it with a discover result and let - /// the client run the 2026 stateless lifecycle at a 2025 version. + /// `server/discover` opens the 2026-07-28 lifecycle, so it is answered + /// only at a revision without a handshake, and lists only those + /// revisions. A probe at a 2025 version is told the method does not + /// exist, which is how a 2025-11-25 server answers it: rmcp's default + /// would answer and let the client run the stateless lifecycle at a + /// revision that defines none. (A version this server does not speak at + /// all never gets here: rmcp refuses it with `-32022` first.) async fn discover( &self, - _context: RequestContext, - ) -> Result { - Err(McpError::method_not_found::< - rmcp::model::DiscoverRequestMethod, - >()) + context: RequestContext, + ) -> Result { + if context + .meta + .protocol_version() + .is_none_or(|version| version.has_initialize()) + { + return Err(McpError::method_not_found::()); + } + let stateless = self + .supported_protocol_versions() + .iter() + .filter(|version| !version.has_initialize()) + .cloned() + .collect(); + Ok(DiscoverResult::from_server_info(stateless, self.get_info())) } fn get_info(&self) -> ServerConfig { - // No `tasks`: the SEP-1686 (MCP 2025-11-25) task surface was retired, - // measured unused — every rmcp 3.x release removes it (0.8.0). + // `tasks` is the SEP-2663 extension (`extensions`), offered on both + // revisions: a client opts in by declaring it, and only a slow call + // from one that did ever becomes a task. The SEP-1686 `capabilities. + // tasks` slot retired in 0.8.0 stays gone. let capabilities = ServerCapabilities::builder() .enable_tools() .enable_prompts() .enable_resources() .enable_completions() + .enable_tasks() .build(); ServerConfig::new(capabilities) - .with_protocol_version(PROTOCOL_VERSION) + // The `initialize` answer: the newest revision that still has a + // handshake. rmcp answers a client asking for 2026-07-28 there + // with this too. + .with_protocol_version(ProtocolVersion::LATEST_WITH_INITIALIZE) // Without this the server announces itself as "rmcp" — rmcp's // Implementation::from_build_env() bakes in its own crate name. // Version carries the build SHA so `initialize` responses and logs @@ -696,11 +809,25 @@ where build = env!("AGENTMAIL_BUILD_SHA"), "agentmail MCP server starting" ); - let server = AgentMailServer::new_embedded(mk); + serve_server(transport, AgentMailServer::new_embedded(mk)).await +} + +/// Serve one connection, then end its tasks: nothing can poll them once the +/// client is gone. +async fn serve_server( + transport: T, + server: AgentMailServer, +) -> Result<(), Box> +where + T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static, +{ + let tasks = Arc::clone(&server.tasks); let service = server.serve(transport).await.inspect_err(|e| { eprintln!("agentmail: server error: {}", e); })?; - service.waiting().await?; + let result = service.waiting().await; + tasks.shutdown().await; + result?; Ok(()) } @@ -710,6 +837,7 @@ where pub async fn serve_stdio(mk: crate::Agentmail) -> Result<(), Box> { let server = AgentMailServer::new(mk); let prewarm_agentmail = Arc::clone(&server.agentmail); + let tasks = Arc::clone(&server.tasks); let service = server .serve(rmcp::transport::io::stdio()) .await @@ -758,6 +886,7 @@ pub async fn serve_stdio(mk: crate::Agentmail) -> Result<(), Box>, + writer: WriteHalf, + next_id: i64, + } + + impl Wire { + /// A server whose every eligible call is promoted at once, and a + /// session that declared the tasks extension. + async fn start() -> Self { + let config = crate::Config::from_accounts(vec![( + "dummy".to_string(), + crate::config::AccountConfig { + // Never connects: `.invalid` cannot resolve, so the call + // has to wait on the network before it fails. + host: "imap.invalid".to_string(), + port: 993, + username: "dummy@example.invalid".to_string(), + email: None, + aliases: Vec::new(), + display_name: None, + password: Some(crate::secret::Secret::new_raw("unused")), + tls: true, + max_connections: None, + auth: crate::config::AuthMethod::Password, + }, + )]); + let server = AgentMailServer::new_embedded_with( + crate::Agentmail::new(config), + tasks::TaskConfig { + promote_after: std::time::Duration::ZERO, + ..tasks::TaskConfig::DEFAULT + }, + ); + let (client_io, server_io) = tokio::io::duplex(1024 * 1024); + tokio::spawn(async move { + let _ = serve_server(server_io, server).await; + }); + let (reader, writer) = tokio::io::split(client_io); + let mut wire = Self { + reader: BufReader::new(reader), + writer, + next_id: 0, + }; + let init = wire + .request( + "initialize", + json!({ + "protocolVersion": "2025-11-25", + "capabilities": { + "extensions": { "io.modelcontextprotocol/tasks": {} } + }, + "clientInfo": { "name": "agentmail-tests", "version": "0" } + }), + ) + .await; + assert_eq!(init["result"]["protocolVersion"], "2025-11-25", "{init:#}"); + wire.send(json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })) + .await; + wire + } + + async fn send(&mut self, frame: Value) { + let mut line = serde_json::to_string(&frame).unwrap(); + line.push('\n'); + self.writer.write_all(line.as_bytes()).await.unwrap(); + } + + async fn request(&mut self, method: &str, params: Value) -> Value { + self.next_id += 1; + let id = self.next_id; + self.send(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })) + .await; + tokio::time::timeout(std::time::Duration::from_secs(30), async { + loop { + let mut line = String::new(); + let read = self.reader.read_line(&mut line).await.unwrap(); + assert!(read > 0, "server closed the stream awaiting `{method}`"); + let frame: Value = serde_json::from_str(&line).unwrap(); + if frame["id"].as_i64() == Some(id) { + return frame; + } + } + }) + .await + .unwrap_or_else(|_| panic!("no answer to `{method}`")) + } + } + + /// The whole flow a task-aware client sees: the call is answered with a + /// task handle, `tasks/get` reports it working, then completed, and the + /// result is the tool's own (an `isError` result here, since the account + /// cannot connect, which SEP-2663 counts as `completed`). + #[tokio::test] + async fn a_slow_call_becomes_a_task_the_client_polls_to_its_result() { + let mut wire = Wire::start().await; + let call = wire + .request( + "tools/call", + json!({ "name": "top_senders", "arguments": { "account": "dummy" } }), + ) + .await; + let created = &call["result"]; + assert_eq!(created["resultType"], "task", "{call:#}"); + assert_eq!(created["status"], "working", "{call:#}"); + assert_eq!(created["statusMessage"], "Running top_senders", "{call:#}"); + let task_id = created["taskId"].as_str().expect("a task id").to_string(); + + let mut last = Value::Null; + for _ in 0..300 { + last = wire + .request("tasks/get", json!({ "taskId": task_id })) + .await; + if last["result"]["status"] != "working" { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + assert_eq!(last["result"]["status"], "completed", "{last:#}"); + assert_eq!(last["result"]["result"]["isError"], true, "{last:#}"); + } + + /// A tool outside the task list is never promoted, however long it takes. + #[tokio::test] + async fn a_tool_outside_the_task_list_answers_inline() { + let mut wire = Wire::start().await; + let call = wire + .request( + "tools/call", + json!({ "name": "check_connection", "arguments": { "account": "dummy" } }), + ) + .await; + assert!(call["result"].get("taskId").is_none(), "{call:#}"); + assert!(call["result"]["content"].is_array(), "{call:#}"); + } +} diff --git a/src/mcp/tasks.rs b/src/mcp/tasks.rs new file mode 100644 index 0000000..78b91f8 --- /dev/null +++ b/src/mcp/tasks.rs @@ -0,0 +1,790 @@ +//! MCP Tasks (SEP-2663, the `io.modelcontextprotocol/tasks` extension): a slow +//! tool call becomes a task the client polls, instead of a request it has to +//! hold open past its own timeout. +//! +//! **The server decides, and decides late.** A call becomes a task only when +//! all four hold: the tool is one of [`TASK_TOOLS`], the client declared the +//! extension, the call is still running after [`TaskConfig::promote_after`], +//! and admission ([`TaskConfig::max_active`], [`TaskConfig::max_tracked`]) +//! lets it in. Anything else runs exactly as a plain call always has, so a +//! client that never declared tasks sees no difference at all. Promoting late +//! rather than deciding up front keeps the quick calls quick: a task costs the +//! client a `tasks/get` round trip and keeps its result for hours, which is +//! only worth it for a call that would have outlived a request anyway. +//! +//! **The token rule.** rmcp cancels a request's token when it sends that +//! request's response (`Event::ToSink` in its service loop). A task's response +//! is the `CreateTaskResult`, sent the moment the call is promoted, so an +//! operation running on the request's token would cancel itself right there. +//! The SEP-1686 tasks this crate retired in 0.8.0 had exactly that bug until +//! their token was re-parented. Every promotable operation therefore runs on a +//! child of the server's own lifetime token, linked to the request's token +//! only while it is still a request. +//! +//! **Cancellation is cooperative.** `tasks/cancel` cancels the operation's +//! token, and the operation stops at its next check, never mid-write. Its +//! terminal status follows what it did: `cancelled` when it saw the +//! cancellation, `completed` when it finished first (SEP-2663 lets a task +//! reach a non-`cancelled` status after a cancel). rmcp's TTL is the only hard +//! stop. +//! +//! **Progress.** Before promotion a call reports `notifications/progress` on +//! the request's token, as it always did. After, that request has been +//! answered, so progress becomes the task's status message, and `tasks/get` +//! carries the numbers under [`PROGRESS_META_KEY`] for a host that relays them +//! as progress of its own. + +use std::{ + collections::{HashMap, VecDeque}, + future::Future, + sync::{ + Arc, OnceLock, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant}, +}; + +use rmcp::{ + ErrorData as McpError, + model::{ + CallToolResponse, CallToolResult, CreateTaskResult, GetTaskResult, InputResponses, + MetaObject, Task, + }, + task_manager::{TaskContext, TaskExit, TaskManager, TaskOptions}, +}; +use tokio::task::{JoinError, JoinHandle}; +use tokio_util::sync::CancellationToken; + +/// The tools a call to which may become a task: the ones that can outlive a +/// request. Account-wide scans, bulk moves and deletes, unsubscribing (which +/// can clean up afterwards), downloads and thread exports. A lookup that +/// answers from one mailbox page never needs one. +pub(super) const TASK_TOOLS: &[&str] = &[ + "search_messages", + "list_flags", + "find_attachments", + "top_senders", + "top_domains", + "top_subscriptions", + "top_mailing_lists", + "preview_thread_record", + "delete_messages", + "delete_by_sender", + "delete_by_domain", + "delete_list_id", + "move_by_sender", + "move_by_domain", + "move_list_id", + "move_subscription", + "unsubscribe_message", + "reconcile_moves", + "download_attachments", + "download_message_source", + "download_thread", + "export_thread_record", +]; + +/// The `_meta` key under which `tasks/get` reports a running task's latest +/// progress, as `{"progress": n, "total": m}`. The request's own progress +/// token died with the request, so this is how a host keeps relaying it. +pub(super) const PROGRESS_META_KEY: &str = "io.agentmuse/progress"; + +#[derive(Debug, Clone, Copy)] +pub(super) struct TaskConfig { + /// How long a call runs as a plain request before it may become a task. + pub(super) promote_after: Duration, + /// rmcp's hard stop: a task still running this long after it was created + /// is aborted and marked `failed`, and a finished one is kept this long + /// after it ends. + pub(super) ttl: Duration, + /// The polling interval the task suggests to the client. + pub(super) poll_interval: Duration, + /// Tasks running at once. A call past this stays a request. + pub(super) max_active: usize, + /// Tasks held at once, running and finished, so finished results cannot + /// pile up for a whole TTL. A call past this stays a request. + pub(super) max_tracked: usize, + /// How long running tasks get to stop when the connection closes, before + /// whatever is left is aborted. + pub(super) shutdown_grace: Duration, +} + +impl TaskConfig { + pub(super) const DEFAULT: Self = Self { + promote_after: Duration::from_secs(2), + ttl: Duration::from_secs(4 * 60 * 60), + poll_interval: Duration::from_secs(1), + max_active: 32, + max_tracked: 1_024, + shutdown_grace: Duration::from_secs(5), + }; +} + +tokio::task_local! { + /// The promotable call the current tool is running for, if any. + static TOOL_RUN: Arc; +} + +/// One promotable call, as seen by the progress and cancel callbacks its tool +/// builds ([`super::make_progress_fn`], [`super::make_cancel_fn`]). +#[derive(Default)] +pub(super) struct ToolRun { + /// Set once, when the call is promoted: from then on it is a task. + task: OnceLock, + /// Whether the operation saw a cancellation through its `CancelFn`. + cancel_observed: AtomicBool, +} + +struct PromotedRun { + context: TaskContext, + progress: Arc, +} + +impl ToolRun { + /// The call the current tool is running for, or `None` outside a + /// promotable call. + pub(super) fn current() -> Option> { + TOOL_RUN.try_with(Arc::clone).ok() + } + + pub(super) fn is_promoted(&self) -> bool { + self.task.get().is_some() + } + + /// Record a progress update on the task, once the call is one. Returns + /// `false` while it is still a request, whose progress is a notification. + pub(super) fn report_progress(&self, completed: u64, total: u64) -> bool { + let Some(promoted) = self.task.get() else { + return false; + }; + promoted + .context + .set_status_message(format!("{completed} of {total}")); + promoted + .progress + .record(promoted.context.task_id(), completed, total); + true + } + + pub(super) fn note_cancel_observed(&self) { + self.cancel_observed.store(true, Ordering::Relaxed); + } + + fn cancel_observed(&self) -> bool { + self.cancel_observed.load(Ordering::Relaxed) + } +} + +/// The latest progress of each task, for `tasks/get`. +#[derive(Default)] +struct ProgressBoard(parking_lot::Mutex>); + +impl ProgressBoard { + fn record(&self, task_id: &str, completed: u64, total: u64) { + self.0 + .lock() + .insert(task_id.to_string(), (completed, total)); + } + + fn get(&self, task_id: &str) -> Option<(u64, u64)> { + self.0.lock().get(task_id).copied() + } + + fn forget(&self, task_id: &str) { + self.0.lock().remove(task_id); + } +} + +/// The spawned operation behind a call. Aborted if dropped before it ends, so +/// nothing outlives the request or task that owns it. +struct Operation(JoinHandle>); + +impl Operation { + async fn join(&mut self) -> Result, JoinError> { + (&mut self.0).await + } +} + +impl Drop for Operation { + fn drop(&mut self) { + self.0.abort(); + } +} + +/// The tasks of one connection: the rmcp store, the lifetime every operation +/// token descends from, and the admission bounds. +pub(super) struct TaskRuntime { + manager: TaskManager, + lifetime: CancellationToken, + config: TaskConfig, + /// Tasks created here that rmcp may still hold, with when they were made. + tracked: parking_lot::Mutex>, + progress: Arc, +} + +impl TaskRuntime { + pub(super) fn new(config: TaskConfig) -> Self { + Self { + manager: TaskManager::new(), + lifetime: CancellationToken::new(), + config, + tracked: parking_lot::Mutex::new(VecDeque::new()), + progress: Arc::new(ProgressBoard::default()), + } + } + + /// A token for one operation: a child of this connection's lifetime, so + /// it outlives the request that started it (see the module doc). + pub(super) fn operation_token(&self) -> CancellationToken { + self.lifetime.child_token() + } + + /// Run one promotable call. + /// + /// `operation` runs on `operation_ct` (from [`Self::operation_token`]). + /// While the call is a request, a cancellation of `request_ct` (a + /// `notifications/cancelled`, or the connection closing) is passed on to + /// the operation, which then stops at its next check exactly as a plain + /// call does. Once promoted, the request's token is left behind: rmcp + /// cancels it as it sends the `CreateTaskResult`. + pub(super) async fn run_call( + &self, + tool: &str, + request_ct: CancellationToken, + operation_ct: CancellationToken, + operation: F, + ) -> Result + where + F: Future> + Send + 'static, + { + let run = Arc::new(ToolRun::default()); + let mut operation = Operation(tokio::spawn(TOOL_RUN.scope(Arc::clone(&run), operation))); + let promote_at = tokio::time::sleep(self.config.promote_after); + tokio::pin!(promote_at); + let mut promotable = true; + loop { + tokio::select! { + biased; + joined = operation.join() => return settle_request(joined), + () = request_ct.cancelled() => { + operation_ct.cancel(); + return settle_request(operation.join().await); + } + () = &mut promote_at, if promotable => { + match self.promote(tool, &run, operation, operation_ct.clone()) { + Ok(task) => { + return Ok(CallToolResponse::Task(CreateTaskResult::new(task))); + } + // Not admitted: the call carries on as a request. + Err(not_admitted) => { + operation = not_admitted; + promotable = false; + } + } + } + } + } + } + + /// Turn a running call into a task, or hand the operation back when + /// admission refuses it. + fn promote( + &self, + tool: &str, + run: &Arc, + operation: Operation, + operation_ct: CancellationToken, + ) -> Result { + let mut tracked = self.tracked.lock(); + if !self.admits(&mut tracked) { + tracing::debug!( + target: "agentmail", + tool, + running = self.manager.running_task_count(), + tracked = tracked.len(), + "slow call stays a request: task admission is full" + ); + return Err(operation); + } + let run = Arc::clone(run); + let progress = Arc::clone(&self.progress); + let options = TaskOptions::new() + .with_ttl_ms(duration_ms(self.config.ttl)) + .with_poll_interval_ms(duration_ms(self.config.poll_interval)) + .with_status_message(format!("Running {tool}")); + let task = self.manager.spawn(options, move |context| { + // From here on the call is a task: progress becomes its status. + let _ = run.task.set(PromotedRun { + context: context.clone(), + progress, + }); + Box::pin(drive_task(operation, operation_ct, run, context)) + }); + tracked.push_back((task.task_id.clone(), Instant::now())); + tracing::info!( + target: "agentmail", + tool, + task = %task.task_id, + "slow call became a task" + ); + Ok(task) + } + + /// Whether one more task fits, after forgetting the ones rmcp has evicted. + fn admits(&self, tracked: &mut VecDeque<(String, Instant)>) -> bool { + if self.lifetime.is_cancelled() { + return false; + } + // rmcp keeps a finished task for one TTL after it ends, so a task + // younger than one TTL cannot have been evicted yet. + tracked.retain(|(task_id, created)| { + let held = + created.elapsed() < self.config.ttl || self.manager.get_task(task_id).is_ok(); + if !held { + self.progress.forget(task_id); + } + held + }); + self.manager.running_task_count() < self.config.max_active + && tracked.len() < self.config.max_tracked + } + + /// `tasks/get`, with the latest progress under [`PROGRESS_META_KEY`]. + pub(super) fn get(&self, task_id: &str) -> Result { + let mut result = GetTaskResult::new(self.manager.get_task(task_id)?); + if let Some((progress, total)) = self.progress.get(task_id) { + let mut meta = MetaObject::default(); + meta.0.insert( + PROGRESS_META_KEY.to_string(), + serde_json::json!({ "progress": progress, "total": total }), + ); + result.meta = Some(meta); + } + Ok(result) + } + + /// `tasks/update`. No tool here asks for input mid-task, so this only + /// validates the task id; rmcp ignores responses nothing is waiting for. + pub(super) fn update(&self, task_id: &str, responses: InputResponses) -> Result<(), McpError> { + self.manager.update_task(task_id, responses) + } + + /// `tasks/cancel`: records the request; [`drive_task`] passes it on. + pub(super) fn cancel(&self, task_id: &str) -> Result<(), McpError> { + self.manager.cancel_task(task_id) + } + + /// The connection is gone, and its tasks with it: nothing can poll them + /// any more. Ask every operation to stop, give them + /// [`TaskConfig::shutdown_grace`] to do so, then abort what is left. + pub(super) async fn shutdown(&self) { + self.lifetime.cancel(); + let deadline = tokio::time::Instant::now() + self.config.shutdown_grace; + while self.manager.running_task_count() > 0 && tokio::time::Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(50)).await; + } + self.manager.shutdown(); + } +} + +/// The body of a task: wait for the operation, passing `tasks/cancel` on to it. +async fn drive_task( + mut operation: Operation, + operation_ct: CancellationToken, + run: Arc, + context: TaskContext, +) -> Result { + let joined = tokio::select! { + biased; + joined = operation.join() => joined, + () = context.cancelled() => { + operation_ct.cancel(); + operation.join().await + } + }; + settle_task(joined, &run, &context) +} + +/// A task's terminal status. An operation that stopped because it was asked +/// to is `cancelled`, whatever it made of the cancellation (most tools report +/// it as an `isError` result). An `isError` result is otherwise `completed`: +/// SEP-2663 reserves `failed` for a call that produced no result. +fn settle_task( + joined: Result, JoinError>, + run: &ToolRun, + context: &TaskContext, +) -> Result { + if context.is_cancel_requested() && run.cancel_observed() { + return Err(TaskExit::Cancelled); + } + match joined { + Ok(Ok(CallToolResponse::Complete(result))) => Ok(result), + Ok(Ok(_)) => Err(TaskExit::Error(McpError::internal_error( + "the tool answered with something other than a result, which a task cannot carry", + None, + ))), + Ok(Err(error)) => Err(TaskExit::Error(error)), + Err(join_error) => Err(TaskExit::Error(operation_lost(&join_error))), + } +} + +/// A call's answer while it is still a request. +fn settle_request( + joined: Result, JoinError>, +) -> Result { + joined.unwrap_or_else(|join_error| Err(operation_lost(&join_error))) +} + +fn operation_lost(join_error: &JoinError) -> McpError { + if join_error.is_panic() { + McpError::internal_error("the tool panicked", None) + } else { + McpError::internal_error("the tool was stopped before it finished", None) + } +} + +fn duration_ms(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + use rmcp::model::{ContentBlock, TaskStatus}; + + fn quick() -> TaskConfig { + TaskConfig { + promote_after: Duration::from_millis(20), + ..TaskConfig::DEFAULT + } + } + + fn text(result: &CallToolResult) -> String { + result + .content + .iter() + .filter_map(|block| block.as_text().map(|text| text.text.clone())) + .collect() + } + + fn done(message: &str) -> Result { + Ok(CallToolResponse::Complete(CallToolResult::success(vec![ + ContentBlock::text(message.to_string()), + ]))) + } + + async fn settled(runtime: &TaskRuntime, task_id: &str) -> GetTaskResult { + for _ in 0..200 { + let result = runtime.get(task_id).expect("task is held"); + if result.task.status().is_terminal() { + return result; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("task {task_id} never settled"); + } + + fn task_id(response: Result) -> String { + match response { + Ok(CallToolResponse::Task(created)) => created.task.task_id, + other => panic!("expected a task, got {other:?}"), + } + } + + /// A call that finishes inside the budget is answered inline and never + /// becomes a task. + #[tokio::test] + async fn a_quick_call_stays_a_request() { + let runtime = TaskRuntime::new(quick()); + let response = runtime + .run_call( + "top_senders", + CancellationToken::new(), + runtime.operation_token(), + async { done("quick") }, + ) + .await; + match response { + Ok(CallToolResponse::Complete(result)) => assert_eq!(text(&result), "quick"), + other => panic!("expected an inline result, got {other:?}"), + } + assert!(runtime.tracked.lock().is_empty()); + } + + /// The regression the token rule exists for: rmcp cancels the request's + /// token as it sends the `CreateTaskResult`, and the task must carry on + /// regardless and complete with the operation's own result. + #[tokio::test] + async fn a_promoted_task_outlives_its_request_token() { + let runtime = TaskRuntime::new(quick()); + let request_ct = CancellationToken::new(); + let operation_ct = runtime.operation_token(); + let seen_by_operation = operation_ct.clone(); + let response = runtime + .run_call( + "top_senders", + request_ct.clone(), + operation_ct, + async move { + tokio::time::sleep(Duration::from_millis(80)).await; + if seen_by_operation.is_cancelled() { + return done("cancelled"); + } + done("finished") + }, + ) + .await; + let task_id = task_id(response); + // What rmcp does when it sends the response. + request_ct.cancel(); + let result = settled(&runtime, &task_id).await; + assert_eq!(result.task.status(), TaskStatus::Completed, "{result:?}"); + let payload = serde_json::to_value(&result).unwrap(); + assert_eq!( + payload["result"]["content"][0]["text"], "finished", + "{payload:#}" + ); + } + + /// `notifications/cancelled` before promotion stops the operation at its + /// next check, the way a plain call stops, and nothing becomes a task. + #[tokio::test] + async fn a_request_cancelled_before_promotion_stops_its_operation() { + let runtime = TaskRuntime::new(TaskConfig { + promote_after: Duration::from_secs(60), + ..TaskConfig::DEFAULT + }); + let request_ct = CancellationToken::new(); + let operation_ct = runtime.operation_token(); + let watched = operation_ct.clone(); + let cancel = request_ct.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + cancel.cancel(); + }); + let response = runtime + .run_call("top_senders", request_ct, operation_ct, async move { + watched.cancelled().await; + done("stopped") + }) + .await; + match response { + Ok(CallToolResponse::Complete(result)) => assert_eq!(text(&result), "stopped"), + other => panic!("expected the operation's own answer, got {other:?}"), + } + assert!(runtime.tracked.lock().is_empty()); + } + + /// `tasks/cancel` reaches the operation through its token; one that saw + /// the cancellation settles as `cancelled`, even though the tool reported + /// it as an ordinary (`isError`) result. + #[tokio::test] + async fn tasks_cancel_settles_an_operation_that_saw_it_as_cancelled() { + let runtime = TaskRuntime::new(quick()); + let operation_ct = runtime.operation_token(); + let watched = operation_ct.clone(); + let response = runtime + .run_call( + "delete_by_sender", + CancellationToken::new(), + operation_ct, + async move { + watched.cancelled().await; + // What `make_cancel_fn` records when the library checks it. + if let Some(run) = ToolRun::current() { + run.note_cancel_observed(); + } + Ok(CallToolResponse::Complete(CallToolResult::error(vec![ + ContentBlock::text("Operation cancelled".to_string()), + ]))) + }, + ) + .await; + let task_id = task_id(response); + runtime.cancel(&task_id).unwrap(); + let result = settled(&runtime, &task_id).await; + assert_eq!(result.task.status(), TaskStatus::Cancelled, "{result:?}"); + } + + /// A cancel that arrives after the work is done changes nothing: the task + /// reports what the operation did. + #[tokio::test] + async fn an_operation_that_finishes_despite_a_cancel_completes() { + let runtime = TaskRuntime::new(quick()); + let response = runtime + .run_call( + "delete_by_sender", + CancellationToken::new(), + runtime.operation_token(), + async { + tokio::time::sleep(Duration::from_millis(60)).await; + done("all deleted") + }, + ) + .await; + let task_id = task_id(response); + runtime.cancel(&task_id).unwrap(); + let result = settled(&runtime, &task_id).await; + assert_eq!(result.task.status(), TaskStatus::Completed, "{result:?}"); + } + + /// A tool that panics after promotion fails its task instead of leaving it + /// `working` until the TTL (rmcp's own completion path never runs for a + /// future that unwinds). + #[tokio::test] + async fn a_panicking_operation_fails_its_task() { + let runtime = TaskRuntime::new(quick()); + let response = runtime + .run_call( + "top_domains", + CancellationToken::new(), + runtime.operation_token(), + async { + tokio::time::sleep(Duration::from_millis(60)).await; + panic!("boom"); + }, + ) + .await; + let task_id = task_id(response); + let result = settled(&runtime, &task_id).await; + assert_eq!(result.task.status(), TaskStatus::Failed, "{result:?}"); + } + + /// A panic while the call is still a request is an internal error, not a + /// request that never gets an answer. + #[tokio::test] + async fn a_panic_before_promotion_is_an_error_response() { + let runtime = TaskRuntime::new(TaskConfig { + promote_after: Duration::from_secs(60), + ..TaskConfig::DEFAULT + }); + let response = runtime + .run_call( + "top_domains", + CancellationToken::new(), + runtime.operation_token(), + async { + panic!("boom"); + }, + ) + .await; + let error = response.expect_err("a panic is an error"); + assert!(error.message.contains("panicked"), "{error:?}"); + } + + /// rmcp's TTL is the hard stop: a task still running past it is aborted + /// and fails, and its operation does not run on. + #[tokio::test] + async fn the_ttl_aborts_a_task_that_runs_past_it() { + let runtime = TaskRuntime::new(TaskConfig { + ttl: Duration::from_millis(150), + ..quick() + }); + let finished = Arc::new(AtomicBool::new(false)); + let flag = Arc::clone(&finished); + let response = runtime + .run_call( + "top_senders", + CancellationToken::new(), + runtime.operation_token(), + async move { + tokio::time::sleep(Duration::from_millis(400)).await; + flag.store(true, Ordering::SeqCst); + done("too late") + }, + ) + .await; + let task_id = task_id(response); + tokio::time::sleep(Duration::from_millis(200)).await; + let result = settled(&runtime, &task_id).await; + assert_eq!(result.task.status(), TaskStatus::Failed, "{result:?}"); + tokio::time::sleep(Duration::from_millis(300)).await; + assert!( + !finished.load(Ordering::SeqCst), + "the aborted operation must not run on" + ); + } + + /// Progress after promotion lands on the task: its status message and the + /// numbers `tasks/get` reports under `io.agentmuse/progress`. + #[tokio::test] + async fn progress_after_promotion_is_the_tasks_status() { + let runtime = TaskRuntime::new(quick()); + let response = runtime + .run_call( + "top_senders", + CancellationToken::new(), + runtime.operation_token(), + async { + tokio::time::sleep(Duration::from_millis(60)).await; + let run = ToolRun::current().expect("running inside a promotable call"); + assert!(run.is_promoted()); + assert!(run.report_progress(3, 10)); + tokio::time::sleep(Duration::from_millis(200)).await; + done("ranked") + }, + ) + .await; + let task_id = task_id(response); + tokio::time::sleep(Duration::from_millis(120)).await; + let running = runtime.get(&task_id).unwrap(); + assert_eq!(running.task.task.status_message.as_deref(), Some("3 of 10")); + let meta = running.meta.expect("progress meta"); + assert_eq!( + meta.0.get(PROGRESS_META_KEY), + Some(&serde_json::json!({ "progress": 3, "total": 10 })) + ); + } + + /// Admission is a ceiling, not a refusal: a call past it simply finishes + /// as a request. + #[tokio::test] + async fn a_call_past_admission_finishes_as_a_request() { + let runtime = TaskRuntime::new(TaskConfig { + max_active: 0, + ..quick() + }); + let response = runtime + .run_call( + "top_senders", + CancellationToken::new(), + runtime.operation_token(), + async { + tokio::time::sleep(Duration::from_millis(60)).await; + done("inline after all") + }, + ) + .await; + match response { + Ok(CallToolResponse::Complete(result)) => assert_eq!(text(&result), "inline after all"), + other => panic!("expected an inline result, got {other:?}"), + } + } + + /// Closing the connection cancels every operation token, and what does + /// not stop within the grace period is aborted. + #[tokio::test] + async fn shutdown_stops_running_tasks() { + let runtime = TaskRuntime::new(TaskConfig { + shutdown_grace: Duration::from_millis(100), + ..quick() + }); + let operation_ct = runtime.operation_token(); + let watched = operation_ct.clone(); + let response = runtime + .run_call( + "top_senders", + CancellationToken::new(), + operation_ct, + async move { + watched.cancelled().await; + done("stopped for shutdown") + }, + ) + .await; + let task_id = task_id(response); + runtime.shutdown().await; + assert_eq!(runtime.manager.running_task_count(), 0); + assert!(runtime.get(&task_id).is_err(), "shutdown drops every task"); + } +} diff --git a/tests/mcp_integration.rs b/tests/mcp_integration.rs index 657ebbd..c479db2 100644 --- a/tests/mcp_integration.rs +++ b/tests/mcp_integration.rs @@ -210,12 +210,19 @@ async fn initialize_reports_capabilities_and_identity() { "missing `{cap}` capability: {caps:?}" ); } - // The SEP-1686 (v1) task surface is retired (0.8.0): measured unused, and - // every rmcp 3.x release removes it. + // Tasks are the SEP-2663 extension, never the SEP-1686 (v1) slot retired + // in 0.8.0. assert!( !caps.contains_key("tasks"), - "`tasks` must not be advertised: {caps:?}" + "the v1 `tasks` slot must not be advertised: {caps:?}" ); + assert!( + caps["extensions"] + .as_object() + .is_some_and(|extensions| extensions.contains_key("io.modelcontextprotocol/tasks")), + "the tasks extension is offered: {caps:?}" + ); + assert_eq!(init["protocolVersion"], "2025-11-25"); assert_eq!( init["serverInfo"]["name"].as_str(), @@ -606,12 +613,11 @@ async fn no_tool_declares_task_support() { } } -/// A task-augmented call RUNS as a plain call. The server declares no -/// `tasks`, and the 2025-11-25 spec says a receiver without the capability -/// "MUST process requests of that type normally, ignoring any -/// task-augmentation metadata"; rmcp 3.x does so by construction (its call -/// params have no `task` field). rmcp 2.2 refused it with a JSON-RPC error. -/// The dummy account cannot connect, so the run shows as an `isError` RESULT. +/// The SEP-1686 `task` parameter asks for nothing any more: SEP-2663 leaves +/// the decision to the server, and only for a client that declared the +/// extension, which this one did not. So the call RUNS as a plain call (rmcp +/// 3.x has no `task` field to read). The dummy account cannot connect, so the +/// run shows as an `isError` RESULT. #[tokio::test] async fn a_task_augmented_call_runs_as_a_plain_call() { let mut client = McpClient::start().await; @@ -661,18 +667,25 @@ fn dummy_config() -> Config { )]) } +/// The `_meta` a 2026-07-28 client puts on every request: its version, +/// identity and capabilities, since that revision has no handshake to carry +/// them. +fn stateless_meta(version: &str, capabilities: &Value) -> Value { + json!({ + "io.modelcontextprotocol/protocolVersion": version, + "io.modelcontextprotocol/clientInfo": {"name": "agentmail-tests", "version": "0"}, + "io.modelcontextprotocol/clientCapabilities": capabilities, + }) +} + /// The probe a 2026-07-28 client opens with: `server/discover` carrying its /// version, identity and capabilities in `_meta`. fn discover_probe(version: &str) -> Value { - json!({"_meta": { - "io.modelcontextprotocol/protocolVersion": version, - "io.modelcontextprotocol/clientInfo": {"name": "agentmail-tests", "version": "0"}, - "io.modelcontextprotocol/clientCapabilities": {}, - }}) + json!({ "_meta": stateless_meta(version, &json!({})) }) } -/// An `initialize` asking for 2026-07-28 is answered in 2025-11-25, the -/// revision this server speaks, rather than agreed to because rmcp knows it. +/// An `initialize` asking for 2026-07-28 is answered in 2025-11-25: that +/// revision has no handshake, so the newest one with one is the answer. #[tokio::test] async fn a_2026_07_28_client_is_negotiated_down_to_2025_11_25() { let mut client = McpClient::connect(dummy_config()).await; @@ -689,22 +702,58 @@ async fn a_2026_07_28_client_is_negotiated_down_to_2025_11_25() { assert_eq!(resp["result"]["protocolVersion"], "2025-11-25", "{resp:#}"); } -/// A 2026-07-28 probe is refused `-32022` with ONLY the versions this server -/// speaks, which a probing client reads as "legacy, use `initialize`". A probe -/// at a 2025 version is told the method does not exist. Then the legacy -/// handshake still works on the same connection, which is the fallback. +/// A 2026-07-28 probe opens that revision's lifecycle: `server/discover` +/// answers with the revisions that need no handshake (only 2026-07-28; a +/// stateless client at a 2025 version would be running a lifecycle that +/// revision does not define), the tasks extension and this server's identity. +/// Then a request carrying its own `_meta`, with no `initialize` anywhere, +/// is served, in that revision's result shape. #[tokio::test] -async fn server_discover_lists_only_versions_up_to_2025_11_25() { +async fn server_discover_at_2026_07_28_opens_the_stateless_lifecycle() { let mut client = McpClient::connect(dummy_config()).await; let resp = client .request("server/discover", discover_probe("2026-07-28")) .await; - assert_eq!(resp["error"]["code"], -32022, "{resp:#}"); + let result = &resp["result"]; assert_eq!( - resp["error"]["data"]["supported"], - json!(["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"]), + result["supportedVersions"], + json!(["2026-07-28"]), + "{resp:#}" + ); + assert!( + result["capabilities"]["extensions"] + .as_object() + .is_some_and(|extensions| extensions.contains_key("io.modelcontextprotocol/tasks")), + "{resp:#}" + ); + assert_eq!( + result["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], "agentmail", + "{resp:#}" + ); + + let resp = client + .request( + "tools/list", + json!({ "_meta": stateless_meta("2026-07-28", &json!({})) }), + ) + .await; + let result = &resp["result"]; + assert!( + result["tools"] + .as_array() + .is_some_and(|tools| !tools.is_empty()), + "{resp:#}" ); + assert_eq!(result["resultType"], "complete", "{resp:#}"); + assert_eq!(result["cacheScope"], "private", "{resp:#}"); +} +/// A probe at a 2025 version is told the method does not exist, which is how +/// a 2025-11-25 server answers it, and the handshake still works on the same +/// connection: that is the fallback. +#[tokio::test] +async fn server_discover_at_a_2025_version_is_method_not_found() { + let mut client = McpClient::connect(dummy_config()).await; let resp = client .request("server/discover", discover_probe("2025-11-25")) .await; @@ -723,6 +772,59 @@ async fn server_discover_lists_only_versions_up_to_2025_11_25() { assert_eq!(resp["result"]["protocolVersion"], "2025-11-25", "{resp:#}"); } +/// A revision newer than 2026-07-28 is refused `-32022` with every version +/// this server speaks, so a probing client can pick one. +#[tokio::test] +async fn an_unknown_revision_is_refused_with_the_supported_list() { + let mut client = McpClient::connect(dummy_config()).await; + let resp = client + .request("server/discover", discover_probe("2099-01-01")) + .await; + assert_eq!(resp["error"]["code"], -32022, "{resp:#}"); + assert_eq!( + resp["error"]["data"]["supported"], + json!([ + "2024-11-05", + "2025-03-26", + "2025-06-18", + "2025-11-25", + "2026-07-28" + ]), + ); +} + +/// `tasks/*` belongs to clients that declared the extension: a session that +/// did not is refused `-32021`, naming the capability it lacks. +#[tokio::test] +async fn tasks_methods_require_the_declared_extension() { + let mut client = McpClient::start().await; + let resp = client + .request("tasks/get", json!({ "taskId": "nope" })) + .await; + assert_eq!(resp["error"]["code"], -32021, "{resp:#}"); + assert!( + resp["error"]["data"] + .to_string() + .contains("io.modelcontextprotocol/tasks"), + "{resp:#}" + ); +} + +/// A stateless 2026-07-28 client that declared the extension reaches +/// `tasks/get`, and an id this server never issued is an invalid parameter. +#[tokio::test] +async fn an_unknown_task_is_an_invalid_parameter() { + let mut client = McpClient::connect(dummy_config()).await; + let tasks = json!({ "extensions": { "io.modelcontextprotocol/tasks": {} } }); + let resp = client + .request( + "tasks/get", + json!({ "taskId": "nope", "_meta": stateless_meta("2026-07-28", &tasks) }), + ) + .await; + assert_eq!(resp["error"]["code"], -32602, "{resp:#}"); +} + #[tokio::test] async fn draft_attachment_limits_are_in_schema_and_enforced_before_io() { let mut client = McpClient::start().await; From 6d8e44c46a97926d1c2527dee1a9d7a5510da53a Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:46:53 -0700 Subject: [PATCH 18/25] fix: server/discover lists every supported revision, as a -32022 does discover listed only the handshake-free revisions (2026-07-28), to keep a stateless client off 2025. But the version list a -32022 gives and the one discover gives are one fact, and the official conformance suite holds them to it: sep-2575-server-unsupported-version-error requires every version in a -32022 to appear in discover's supportedVersions. The gate on discover itself (method-not-found at a 2025 version) is what keeps a stateless client off 2025, and it stays. Test, MCP.md, CHANGELOG and the 0.9.0 decision follow. --- CHANGELOG.md | 3 ++- DECISIONS.md | 16 +++++++++++----- MCP.md | 4 ++-- src/mcp/mod.rs | 28 +++++++++++++++------------- tests/mcp_integration.rs | 13 +++++++++---- 5 files changed, 39 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6ac292..32bc688 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,7 +65,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **MCP 2026-07-28 alongside 2025-11-25, version 0.9.0** — a 2026-07-28 client runs that revision's stateless lifecycle: `server/discover` answers - with the revisions that need no handshake (2026-07-28 only), every request + with every revision this server implements (the same list a `-32022` + carries), every request carries its own version, identity and capabilities in `_meta`, and results carry `resultType` and cache hints. A 2025-era client still opens with `initialize`, answered in 2025-11-25 exactly as before, and a client asking diff --git a/DECISIONS.md b/DECISIONS.md index 0be37c4..2cac300 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -13,8 +13,9 @@ The server speaks MCP 2026-07-28 and every earlier revision. with `MAX_PROTOCOL_VERSION` = 2026-07-28 written in the source. The `initialize` answer is `LATEST_WITH_INITIALIZE` (2025-11-25), which rmcp also gives a client that asks `initialize` for 2026-07-28. `server/discover` is -answered only at a revision with no handshake, and lists only those (today -2026-07-28 alone). At a 2025 version it is method-not-found. +answered only at a revision with no handshake; at a 2025 version it is +method-not-found. Its `supportedVersions` is every revision the server +implements, the same list a `-32022` carries. ### Rationale @@ -28,9 +29,14 @@ protocol on its own. The discover gate closes a trap in rmcp's default, which answers a probe at any supported version. A client probing at 2025-11-25 would then run the -stateless lifecycle at a revision that defines none. Listing only the -handshake-free revisions in `supportedVersions` closes the same door from the -other side. +stateless lifecycle at a revision that defines none. + +`supportedVersions` first listed only the handshake-free revisions, to close +the same door from the other side. It was wrong: the version list a `-32022` +gives and the one `discover` gives are one fact, and a client must be able to +read them as one. The official suite's `sep-2575-server-unsupported-version-error` +checks exactly that. The gate on `discover` itself is what keeps a stateless +client off 2025. ### Consequence diff --git a/MCP.md b/MCP.md index c20cd83..00fee09 100644 --- a/MCP.md +++ b/MCP.md @@ -9,8 +9,8 @@ MCP protocol: [2026-07-28](https://modelcontextprotocol.io/specification/2026-07 **Two lifecycles, one server.** A 2025-era client opens with `initialize`, answered in 2025-11-25 (also when it asks for 2026-07-28, which has no handshake). A 2026-07-28 client runs that revision's stateless lifecycle: -`server/discover` answers with the revisions that need no handshake -(2026-07-28 only), and every request carries its version, identity and +`server/discover` answers with every revision this server implements (the +list a `-32022` carries too), and every request carries its version, identity and capabilities in `_meta`. `server/discover` at a 2025 version is method-not-found, and a revision newer than 2026-07-28 is refused `-32022` with the supported list. The ceiling is `MAX_PROTOCOL_VERSION`, named in the source: diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 8cb9268..243567b 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -497,12 +497,17 @@ impl ServerHandler for AgentMailServer { } /// `server/discover` opens the 2026-07-28 lifecycle, so it is answered - /// only at a revision without a handshake, and lists only those - /// revisions. A probe at a 2025 version is told the method does not - /// exist, which is how a 2025-11-25 server answers it: rmcp's default - /// would answer and let the client run the stateless lifecycle at a - /// revision that defines none. (A version this server does not speak at - /// all never gets here: rmcp refuses it with `-32022` first.) + /// only at a revision without a handshake. A probe at a 2025 version is + /// told the method does not exist, which is how a 2025-11-25 server + /// answers it: rmcp's default would answer and let the client run the + /// stateless lifecycle at a revision that defines none. (A version this + /// server does not speak at all never gets here: rmcp refuses it with + /// `-32022` first.) + /// + /// `supportedVersions` lists every revision this server implements, the + /// same list a `-32022` carries: a client must be able to read the two + /// answers as one fact (the official suite's `sep-2575` checks hold + /// them to it). async fn discover( &self, context: RequestContext, @@ -514,13 +519,10 @@ impl ServerHandler for AgentMailServer { { return Err(McpError::method_not_found::()); } - let stateless = self - .supported_protocol_versions() - .iter() - .filter(|version| !version.has_initialize()) - .cloned() - .collect(); - Ok(DiscoverResult::from_server_info(stateless, self.get_info())) + Ok(DiscoverResult::from_server_info( + self.supported_protocol_versions().into_owned(), + self.get_info(), + )) } fn get_info(&self) -> ServerConfig { diff --git a/tests/mcp_integration.rs b/tests/mcp_integration.rs index c479db2..343cffe 100644 --- a/tests/mcp_integration.rs +++ b/tests/mcp_integration.rs @@ -703,9 +703,8 @@ async fn a_2026_07_28_client_is_negotiated_down_to_2025_11_25() { } /// A 2026-07-28 probe opens that revision's lifecycle: `server/discover` -/// answers with the revisions that need no handshake (only 2026-07-28; a -/// stateless client at a 2025 version would be running a lifecycle that -/// revision does not define), the tasks extension and this server's identity. +/// answers with every revision this server implements (the list a `-32022` +/// carries too), the tasks extension and this server's identity. /// Then a request carrying its own `_meta`, with no `initialize` anywhere, /// is served, in that revision's result shape. #[tokio::test] @@ -717,7 +716,13 @@ async fn server_discover_at_2026_07_28_opens_the_stateless_lifecycle() { let result = &resp["result"]; assert_eq!( result["supportedVersions"], - json!(["2026-07-28"]), + json!([ + "2024-11-05", + "2025-03-26", + "2025-06-18", + "2025-11-25", + "2026-07-28" + ]), "{resp:#}" ); assert!( From 0ea3c21ebb8ee6058390b006d336cfadbc237ae4 Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:09:48 -0700 Subject: [PATCH 19/25] build: rmcp 3.6.0; the protocol stays where it was rmcp =3.5.1 -> =3.6.0, exact-pinned to the embedding Agent Muse workspace's copy so the app links one rmcp. No API this crate uses changed (ServerHandler, the macros and the transports are identical), and the protocol constants are unchanged. What 3.6.0 changes here: a single incoming stdio message over 16 MiB is dropped (async_rw), a JSON-RPC message carrying both result and error, or method with an unreadable id, is rejected, and server-to-client requests on a 2026-07-28 HTTP session are refused at the source. --- CHANGELOG.md | 2 +- Cargo.lock | 9 +++++---- Cargo.toml | 2 +- DESIGN.md | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32bc688..85f144f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,7 +137,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 rebased onto 0.17.0 (nom 8) and its UIDFETCH patch re-applied; mail-auth 0.12.1 → 0.13.3; dirs 6 → 7; semver-compatible updates across the lockfile. rusqlite stays at 0.39 (the embedding app's sqlx-sqlite caps libsqlite3-sys - below 0.38). rmcp 2.2.0 → 3.5.1, exact-pinned to the embedding app's copy; + below 0.38). rmcp 2.2.0 → 3.6.0, exact-pinned to the embedding app's copy; the MCP revision does not move with it (below). - **`fresh_logins` / `idle_reuses` are labelled as pool-wide** — the log lines that carry them now name them `pool_fresh_logins` and `pool_idle_reuses`, diff --git a/Cargo.lock b/Cargo.lock index 22a5b27..d5fc991 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3001,14 +3001,15 @@ dependencies = [ [[package]] name = "rmcp" -version = "3.5.1" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350" +checksum = "ceb42e9e47443cd83a834dea57b5816d35481417bae72ade5dc5615c59122e26" dependencies = [ "base64 0.23.1", "chrono", "futures", "indexmap", + "libc", "pastey", "pin-project-lite", "rmcp-macros", @@ -3024,9 +3025,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.5.1" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1da498700030856ba63114bceefa30ba21f5e278cb975e5b9de3529fe842b40" +checksum = "2f55ef2d84752d676d735604548fcb65811504e23330d85670b0eff5f8735144" dependencies = [ "darling", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 26b5f17..02a0caf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,7 +58,7 @@ reqwest = { version = "0.13", features = ["json"] } # Exact-pinned to the embedding Agent Muse workspace's rmcp, so the app links # one copy. The protocol does not move with the SDK: the server speaks up to # `mcp::MAX_PROTOCOL_VERSION` (2026-07-28), named in the source. -rmcp = { version = "=3.5.1", features = ["server", "macros", "transport-io"] } +rmcp = { version = "=3.6.0", features = ["server", "macros", "transport-io"] } # Held at 0.39 (libsqlite3-sys ^0.37) so embedding workspaces that also carry # sqlx-sqlite 0.9 (libsqlite3-sys >=0.30.1,<0.38) can converge on one native # sqlite — libsqlite3-sys has `links = "sqlite3"`, so exactly one version may diff --git a/DESIGN.md b/DESIGN.md index f3ddd2e..e745387 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -4,7 +4,7 @@ Agentmail is a cross-platform IMAP email client library with an MCP (Model Context Protocol) server for AI assistant integration. It provides 36 tools and 6 prompts for reading, searching, composing, organizing, archiving, and managing email across multiple accounts. [MCP.md](MCP.md) is the authoritative wire-contract catalog. -MCP protocol: [2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28) (stateless) and [2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25) (`initialize`; also negotiates 2025-06-18, 2025-03-26, and 2024-11-05) | rmcp 3.5.1 +MCP protocol: [2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28) (stateless) and [2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25) (`initialize`; also negotiates 2025-06-18, 2025-03-26, and 2024-11-05) | rmcp 3.6.0 No Mail.app dependency. Pure IMAP over TLS. Works on macOS, Linux, and Windows. From 3bc9169f511ec0868ed778c38423927138c532a2 Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:05:21 -0700 Subject: [PATCH 20/25] fix: closing the connection never cuts a task mid-write TaskRuntime::shutdown asked every operation to stop, gave them five seconds, then had rmcp drop the tasks, and dropping an operation aborted it wherever it was. One chunk of a bulk move on a slow server can take longer than that, so a closing connection (a mail-account edit, a reload) could cut a move between its COPY and its EXPUNGE. Dropped while the connection shuts down, an operation is now detached: its token is already cancelled, so it finishes the chunk it is in and stops at its next check. Any other drop (rmcp's TTL, the one hard stop) cancels the operation's token before aborting it, so work an abort cannot reach stops too. --- CHANGELOG.md | 5 ++- DECISIONS.md | 9 ++++- src/mcp/tasks.rs | 102 +++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 102 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85f144f..f8c688e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the task's status message, and `tasks/get` carries the numbers under `_meta["io.agentmuse/progress"]`. Each operation runs on the connection's own token, never its request's, which rmcp cancels as it answers the request. - Closing the connection stops its tasks (5 seconds' grace, then abort). + Closing the connection stops its tasks: each is asked to stop, and one + still running after 5 seconds is detached to finish the chunk it is in and + stop at its next check, never aborted mid-write. A task that outlives its + 4-hour TTL is aborted, with its operation's token cancelled first. - **Sender identity** — `display_name` in an account's config makes drafts `From: Mark Blake `. `create_draft` and `update_draft` take an optional `from` (`Name
` or an address) that must be one of the diff --git a/DECISIONS.md b/DECISIONS.md index 2cac300..7a9d9ed 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -93,7 +93,14 @@ until its token was re-parented. Here the rule is structural, and pinned by leave a COPY without its EXPUNGE. The library already stops cleanly at its `CancelFn` checks, and now also while queued behind another mutation on its account (`lock_account_mutation_unless_cancelled`). Only rmcp's TTL aborts, -and nothing here runs for 4 hours. +and nothing here runs for 4 hours; even then the operation's token is +cancelled first, so it stops at its next check if the abort cannot reach it. +Closing the connection does NOT abort: an operation still running when the +5-second shutdown grace ends is detached, finishes the chunk it is in, and +stops at its next check (`Operation`'s drop; review of the 0.9.0 work, +2026-10-21, which found the grace ending in an abort that could cut a move +between COPY and EXPUNGE on a slow server). Pinned by +`shutdown_lets_an_operation_reach_its_next_check`. **`cancelled` follows what the tool did.** Most tools report a library error as an `isError` result, so the result alone cannot tell a cancelled scan from diff --git a/src/mcp/tasks.rs b/src/mcp/tasks.rs index 78b91f8..2994b3a 100644 --- a/src/mcp/tasks.rs +++ b/src/mcp/tasks.rs @@ -26,7 +26,9 @@ //! terminal status follows what it did: `cancelled` when it saw the //! cancellation, `completed` when it finished first (SEP-2663 lets a task //! reach a non-`cancelled` status after a cancel). rmcp's TTL is the only hard -//! stop. +//! stop. Closing the connection is not one: an operation still running when +//! the shutdown grace ends is detached, not aborted, and stops at its next +//! check like any cancelled one (see [`Operation`]). //! //! **Progress.** Before promotion a call reports `notifications/progress` on //! the request's token, as it always did. After, that request has been @@ -195,19 +197,39 @@ impl ProgressBoard { } } -/// The spawned operation behind a call. Aborted if dropped before it ends, so -/// nothing outlives the request or task that owns it. -struct Operation(JoinHandle>); +/// The spawned operation behind a call, with the token it runs on and the +/// connection's lifetime. +/// +/// Dropped before it ends, it is stopped, and how depends on why: +/// +/// - The connection is shutting down (its lifetime is cancelled, so this +/// operation's token already is): DETACHED. It runs on to its next +/// cancellation check and ends there. Aborting it at an arbitrary await +/// could cut a bulk move between its COPY and its EXPUNGE, or leave a +/// half-written download, which is exactly what cooperative cancellation +/// exists to prevent; its token being cancelled is what keeps the work +/// bounded. +/// - Anything else (rmcp's TTL, the one hard stop): its token is cancelled +/// first, so work the abort cannot reach still stops at its next check, +/// then it is aborted. +struct Operation { + handle: JoinHandle>, + token: CancellationToken, + lifetime: CancellationToken, +} impl Operation { async fn join(&mut self) -> Result, JoinError> { - (&mut self.0).await + (&mut self.handle).await } } impl Drop for Operation { fn drop(&mut self) { - self.0.abort(); + self.token.cancel(); + if !self.lifetime.is_cancelled() { + self.handle.abort(); + } } } @@ -258,7 +280,11 @@ impl TaskRuntime { F: Future> + Send + 'static, { let run = Arc::new(ToolRun::default()); - let mut operation = Operation(tokio::spawn(TOOL_RUN.scope(Arc::clone(&run), operation))); + let mut operation = Operation { + handle: tokio::spawn(TOOL_RUN.scope(Arc::clone(&run), operation)), + token: operation_ct.clone(), + lifetime: self.lifetime.clone(), + }; let promote_at = tokio::time::sleep(self.config.promote_after); tokio::pin!(promote_at); let mut promotable = true; @@ -376,7 +402,9 @@ impl TaskRuntime { /// The connection is gone, and its tasks with it: nothing can poll them /// any more. Ask every operation to stop, give them - /// [`TaskConfig::shutdown_grace`] to do so, then abort what is left. + /// [`TaskConfig::shutdown_grace`] to do so, then drop the tasks. An + /// operation still running is detached rather than aborted (see + /// [`Operation`]): it stops at its next check, never mid-write. pub(super) async fn shutdown(&self) { self.lifetime.cancel(); let deadline = tokio::time::Instant::now() + self.config.shutdown_grace; @@ -673,7 +701,8 @@ mod tests { } /// rmcp's TTL is the hard stop: a task still running past it is aborted - /// and fails, and its operation does not run on. + /// and fails, and its operation does not run on. Its token is cancelled + /// too, so work an abort cannot reach stops at its next check. #[tokio::test] async fn the_ttl_aborts_a_task_that_runs_past_it() { let runtime = TaskRuntime::new(TaskConfig { @@ -682,11 +711,13 @@ mod tests { }); let finished = Arc::new(AtomicBool::new(false)); let flag = Arc::clone(&finished); + let operation_ct = runtime.operation_token(); + let token = operation_ct.clone(); let response = runtime .run_call( "top_senders", CancellationToken::new(), - runtime.operation_token(), + operation_ct, async move { tokio::time::sleep(Duration::from_millis(400)).await; flag.store(true, Ordering::SeqCst); @@ -703,6 +734,10 @@ mod tests { !finished.load(Ordering::SeqCst), "the aborted operation must not run on" ); + assert!( + token.is_cancelled(), + "the TTL cancels the operation's token" + ); } /// Progress after promotion lands on the task: its status message and the @@ -761,8 +796,8 @@ mod tests { } } - /// Closing the connection cancels every operation token, and what does - /// not stop within the grace period is aborted. + /// Closing the connection cancels every operation token and drops every + /// task. #[tokio::test] async fn shutdown_stops_running_tasks() { let runtime = TaskRuntime::new(TaskConfig { @@ -787,4 +822,47 @@ mod tests { assert_eq!(runtime.manager.running_task_count(), 0); assert!(runtime.get(&task_id).is_err(), "shutdown drops every task"); } + + /// An operation still between two checks when the grace runs out (one + /// chunk of a bulk move on a slow server) is not cut there: it is detached, + /// finishes the chunk, and stops at its next check. Aborting it could + /// leave a COPY without its EXPUNGE. + #[tokio::test] + async fn shutdown_lets_an_operation_reach_its_next_check() { + let runtime = TaskRuntime::new(TaskConfig { + shutdown_grace: Duration::from_millis(100), + ..quick() + }); + let chunk_done = Arc::new(AtomicBool::new(false)); + let flag = Arc::clone(&chunk_done); + let operation_ct = runtime.operation_token(); + let watched = operation_ct.clone(); + let response = runtime + .run_call( + "move_by_sender", + CancellationToken::new(), + operation_ct, + async move { + // Mid-chunk: no check until the chunk is written. + tokio::time::sleep(Duration::from_millis(400)).await; + flag.store(true, Ordering::SeqCst); + if watched.is_cancelled() { + return done("stopped after the chunk"); + } + done("all moved") + }, + ) + .await; + task_id(response); + runtime.shutdown().await; + assert!( + !chunk_done.load(Ordering::SeqCst), + "the grace ended before the chunk did" + ); + tokio::time::sleep(Duration::from_millis(500)).await; + assert!( + chunk_done.load(Ordering::SeqCst), + "the chunk was finished, not cut" + ); + } } From 9ffebc77ae5fca4845299b25a5588f183e2deb69 Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:22:49 -0700 Subject: [PATCH 21/25] update cargo --- Cargo.lock | 56 +++++++++++++++++++++++++++--------------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d5fc991..f642bfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1663,9 +1663,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", @@ -1677,9 +1677,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", @@ -1690,9 +1690,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1704,15 +1704,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ "displaydoc", "icu_collections", @@ -1725,15 +1725,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", @@ -2680,9 +2680,9 @@ dependencies = [ [[package]] name = "psl" -version = "2.1.243" +version = "2.1.244" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6600966e937eeb6e279d7d5a35b9c61ca720f4a5ab6c950a3b921ca4881e0054" +checksum = "bc3ac0c25b0c40554a755e56dfe268855c53f391bbb7922a1c1fe59b73b163a9" dependencies = [ "psl-types", ] @@ -2695,9 +2695,9 @@ checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" [[package]] name = "pulldown-cmark" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +checksum = "278e9268c93b9e498a2da46c34d90ba93a3255eb99009646398f34bc73ab36eb" dependencies = [ "bitflags", "memchr", @@ -3697,9 +3697,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", "zerovec", @@ -3713,9 +3713,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.54.0" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", @@ -4400,9 +4400,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4542,9 +4542,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ "yoke", "zerofrom", @@ -4553,9 +4553,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", From ef69869c3dcfb23c159751466a784a7e9dbe4c8f Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:26:14 -0700 Subject: [PATCH 22/25] bump rmcp --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f642bfd..cddafa4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3001,9 +3001,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "3.6.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ceb42e9e47443cd83a834dea57b5816d35481417bae72ade5dc5615c59122e26" +checksum = "ece64e19631fc38758181e637c15509a2c2709ee53bfde1e1d58f9dc991f919e" dependencies = [ "base64 0.23.1", "chrono", @@ -3025,9 +3025,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.6.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f55ef2d84752d676d735604548fcb65811504e23330d85670b0eff5f8735144" +checksum = "967fa7f7935d7d6217d593df0e8e5c9307bcaf256974a5a9ee64476c97735389" dependencies = [ "darling", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 02a0caf..73fc3ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,7 +58,7 @@ reqwest = { version = "0.13", features = ["json"] } # Exact-pinned to the embedding Agent Muse workspace's rmcp, so the app links # one copy. The protocol does not move with the SDK: the server speaks up to # `mcp::MAX_PROTOCOL_VERSION` (2026-07-28), named in the source. -rmcp = { version = "=3.6.0", features = ["server", "macros", "transport-io"] } +rmcp = { version = "=3.7.0", features = ["server", "macros", "transport-io"] } # Held at 0.39 (libsqlite3-sys ^0.37) so embedding workspaces that also carry # sqlx-sqlite 0.9 (libsqlite3-sys >=0.30.1,<0.38) can converge on one native # sqlite — libsqlite3-sys has `links = "sqlite3"`, so exactly one version may From e0fc449f1931d6c6071f7000fc4a3df3249100ea Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:38:45 -0700 Subject: [PATCH 23/25] bump rust version --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 73fc3ca..62fd018 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ imap-proto = { path = "vendor/imap-proto" } name = "agentmail" version = "0.9.0" edition = "2024" -rust-version = "1.99" +rust-version = "1.99.1" description = "IMAP email client, MCP server, and CLI — read, search, delete, unsubscribe, and manage mailboxes" documentation = "https://docs.rs/agentmail" readme = "README.md" From a570fe1b0f161963f7ac1eb7c48d169ae59a121c Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:10:43 -0700 Subject: [PATCH 24/25] updated --- src/mailbox_catalog.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mailbox_catalog.rs b/src/mailbox_catalog.rs index dd81ede..377ed10 100644 --- a/src/mailbox_catalog.rs +++ b/src/mailbox_catalog.rs @@ -366,8 +366,8 @@ mod tests { let now = SystemTime::now(); let generation = catalog.generation("work"); catalog.store_if_generation("work", generation, vec![entry("INBOX")], now); - - let result = catalog.get_at("work", now + MAILBOX_CATALOG_TTL - Duration::from_nanos(1)); + //Windows stores time in microseconds so no nano + let result = catalog.get_at("work", now + MAILBOX_CATALOG_TTL - Duration::from_micros(1)); assert_eq!(result.as_deref().map(|entries| entries.len()), Some(1)); } From a71c764559467c02129fdead0ed2ba48e646b6f3 Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:14:10 -0700 Subject: [PATCH 25/25] updated build version --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 62fd018..077b486 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ imap-proto = { path = "vendor/imap-proto" } [package] name = "agentmail" -version = "0.9.0" +version = "0.9.1" edition = "2024" rust-version = "1.99.1" description = "IMAP email client, MCP server, and CLI — read, search, delete, unsubscribe, and manage mailboxes"