diff --git a/Cargo.lock b/Cargo.lock index f890479..fa91ea1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,10 +15,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] +[[package]] +name = "aes" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + [[package]] name = "agentmail" version = "0.5.0" @@ -41,6 +52,7 @@ dependencies = [ "native-tls", "parking_lot", "percent-encoding", + "printpdf", "proptest", "psl", "rand 0.10.2", @@ -71,12 +83,56 @@ dependencies = [ "memchr", ] +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "allsorts-azul" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab097a7be305dd66b2b6917f5efb6b723129ce4b2cd11aa1a0afcc293c7dc08e" +dependencies = [ + "bitflags", + "brotli-decompressor", + "encoding_rs", + "enumflags2", + "flate2", + "glyph-names", + "log", + "ouroboros", + "pathfinder_geometry", + "rustc-hash", + "tinyvec", + "ucd-trie", + "unicode-canonical-combining-class", + "unicode-general-category", + "unicode-joining-type", +] + [[package]] name = "android_system_properties" version = "0.1.6" @@ -323,6 +379,25 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bstr" version = "1.13.1" @@ -357,14 +432,23 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "cipher", + "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", ] [[package]] name = "cc" -version = "1.4.3" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "jobserver", @@ -427,7 +511,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", - "inout", + "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", ] [[package]] @@ -458,7 +552,7 @@ version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 3.0.3", @@ -552,6 +646,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -572,9 +672,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -718,9 +818,9 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" dependencies = [ - "aes", - "block-padding", - "cbc", + "aes 0.8.4", + "block-padding 0.3.3", + "cbc 0.1.2", "dbus", "fastrand", "hkdf", @@ -848,11 +948,20 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecb" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "email-encoding" @@ -879,6 +988,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1159,11 +1288,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "glyph-names" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3531d702d6c1a3ba92a5fb55a404c7b8c476c8e7ca249951077afcbe4bc807f" + [[package]] name = "h2" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" dependencies = [ "atomic-waker", "bytes", @@ -1213,6 +1348,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -1531,9 +1672,9 @@ checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1594,10 +1735,20 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", + "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", + "hybrid-array", +] + [[package]] name = "ipconfig" version = "0.3.4" @@ -1797,9 +1948,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lol_html" @@ -1820,6 +1971,32 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "lopdf" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2ec995d822e05cabc3f06d196ee43650af3fe4fe38012cacb35e0c3d113b68" +dependencies = [ + "aes 0.9.2", + "bitflags", + "cbc 0.2.1", + "ecb", + "encoding_rs", + "flate2", + "getrandom 0.4.3", + "indexmap", + "itoa", + "log", + "md-5", + "nom 8.0.0", + "rand 0.10.2", + "rangemap", + "sha2 0.11.0", + "stringprep", + "thiserror 2.0.20", + "weezl", +] + [[package]] name = "lru-cache" version = "0.1.2" @@ -1867,9 +2044,9 @@ dependencies = [ [[package]] name = "mail-parser" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4084ec5c2f90b341d0c70990e92a23b128f75ca14fc1dd5edd8fd5c9b417da4d" +checksum = "0b3a9080c1fb8190e232df37a10aa1b3d6b08be084e537069913f025e0ce86c5" dependencies = [ "encoding_rs", "hashify", @@ -1884,6 +2061,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.8.3" @@ -2140,6 +2327,30 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + [[package]] name = "parking" version = "2.2.1" @@ -2175,6 +2386,25 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" +dependencies = [ + "rustc_version", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2361,6 +2591,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "printpdf" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75e1f6d16f80c8f34918a4cc68743d703e8ee5016fac9508e223eb8e60445a0" +dependencies = [ + "allsorts-azul", + "base64 0.22.1", + "flate2", + "getrandom 0.4.3", + "lopdf", + "serde", + "serde_derive", + "serde_json", + "smallvec", + "time", + "wasm-bindgen", + "wasm-bindgen-futures", + "weezl", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -2370,6 +2621,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", + "yansi", +] + [[package]] name = "proptest" version = "1.11.0" @@ -2391,9 +2655,9 @@ dependencies = [ [[package]] name = "psl" -version = "2.1.225" +version = "2.1.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d71c4a0dab830345e50e8d0dc881bf219d0a9ff261dd2500d36e7cd24427d1e" +checksum = "7bc88482eea924ca3a2f56a547454169af58deef35567965eb4fc2392a834841" dependencies = [ "psl-types", ] @@ -2573,6 +2837,12 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + [[package]] name = "rayon" version = "1.12.0" @@ -2876,9 +3146,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.14" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -3175,6 +3445,9 @@ name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -3204,6 +3477,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "stop-token" version = "0.7.0" @@ -3216,6 +3495,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -3622,18 +3912,63 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unarray" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-canonical-combining-class" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41c99d5174052d02ce765418e826597a1be18f32c114e35d9e22f92390239561" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-joining-type" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d00a78170970967fdb83f9d49b92f959ab2bb829186b113e4f4604ad98e180" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "untrusted" version = "0.9.0" @@ -3666,9 +4001,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.1" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -3820,6 +4155,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2" + [[package]] name = "widestring" version = "1.2.1" @@ -4018,6 +4359,12 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.3" @@ -4115,9 +4462,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.7" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -4126,9 +4473,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 5833c70..3491da1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ mail-auth = { version = "0.12.1", 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"] } rmcp = { version = "=2.2.0", features = ["server", "macros", "transport-io"] } diff --git a/MCP.md b/MCP.md index f5888dc..513d1fd 100644 --- a/MCP.md +++ b/MCP.md @@ -1,6 +1,6 @@ --- created: 2026-05-29T19:20 -updated: 2026-08-04T00:00 +updated: 2026-08-23T00:00 --- # Agentmail MCP — Tool & Prompt Reference @@ -20,7 +20,10 @@ MCP protocol: [2025-11-25](https://modelcontextprotocol.io/specification/2025-11 required; nothing defaults to INBOX, because a UID and `expectedUidValidity` are only meaningful with the mailbox they came from. -## Tools (31) +AgentMail reads, organizes, archives, and saves drafts. It does not expose a +send operation. + +## Tools (37) ### Discovery & Connection @@ -87,6 +90,7 @@ an unknown account raises `-32602`. | 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` | #### Output Schemas @@ -250,28 +254,54 @@ override its root; 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. +**preview_thread_record** + +```json +{ + "account", "seed": MessageIdentity, + "strategy": "exact-rfc-message-id-graph-v1", "rationale", + "messages": [{ + "identity": MessageIdentity, "messageId?", "inReplyTo?", + "references": [], "date?", "from", "subject", "selectionBasis": [] + }], + "selectionDigest", "confirmationRequired": true, + "truncated": false, "warnings": [] +} +``` + +The bounded cross-mailbox graph follows only exact normalized `Message-ID`, +`In-Reply-To`, and `References` values. Subject similarity is never a +selection edge. At most 100 storage identities are returned; a truncated +preview cannot be exported. The digest binds the exact identities and headers +shown by the preview to a later `export_thread_record` confirmation. + --- ### Write / Mutate | # | Tool | Description | Annotations | | --- | ---------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------- | -| 14 | `delete_messages` | Delete by UID (up to 500). Moves to Trash, or permanently expunges when `permanent=true`. | `destructive`, `idempotent`, `taskable` | -| 15 | `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` | -| 16 | `delete_list_id` | Delete all messages with an **exact** List-Id across all mailboxes. `permanent=true` bypasses Trash. | `destructive`, `taskable` | -| 17 | `delete_by_domain` | Delete all messages from one exact canonical domain from `top_domains`; subdomains are never implicit. | `destructive`, `taskable` | -| 18 | `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` | -| 19 | `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` | -| 20 | `move_by_domain` | Move all messages from one exact canonical domain to a destination; subdomains are never implicit. | `taskable` | -| 21 | `move_subscription` | Move the exact bulk-mail subscription represented by a UIDVALIDITY-safe `top_subscriptions` sample; destination excluded. | `taskable` | -| 22 | `move_message` | IMAP MOVE between mailboxes (durable COPY+EXPUNGE fallback when MOVE is unavailable). | | -| 23 | `reconcile_moves` | Safely resume one or all pending COPY-fallback MOVE operations. | `destructive`, `taskable` | -| 24 | `create_mailbox` | Create new folder | `idempotent` | -| 25 | `create_draft` | Compose RFC822 to Drafts folder (to/cc/bcc required; creates Drafts mailbox if missing). Supports optional local file attachments. Returns the draft identity when recoverable. | | -| 26 | `download_attachments` | Extract attachments to disk as `{uid}_{index}_{filename}` | `taskable` | -| 27 | `download_message_source` | Save exact RFC822 bytes directly to disk with SHA-256, metadata, and local DNS-backed DKIM evidence. | `open_world`, `taskable` | -| 28 | `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` | -| 29 | `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` | +| 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 with To/Cc/Bcc/Reply-To, optional threading headers, and attachments. | | +| 29 | `create_reply_draft` | Derive reply or reply-all recipients and RFC threading headers from a live message. | | +| 30 | `update_draft` | Atomically replace a live draft with RFC 8508 REPLACE; refuses APPEND+DELETE. | `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` | **`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). @@ -463,19 +493,68 @@ is disabled. { "account", "mailbox", "created": bool, "alreadyExists": bool } ``` -**create_draft** +**rename_mailbox** / **delete_mailbox** + +The first call leaves `confirmRename` or `confirmDelete` false and returns live +preflight data: + +```json +{ + "account", "mailbox", "newMailbox?", "preview": true, + "renamed?": false, "deleted?": false, "alreadyMissing?": false, + "preflight": { + "messageCount", "roles": [], "descendants": [], + "confirmationsRequired": [] + } +} +``` + +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 +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 +reports success only when the resulting state is unambiguous. + +**create_draft** / **create_reply_draft** ```json { "created": true, "account", "draftsMailbox", "attachmentCount", + "replyToCount", "threadingApplied", "warning?", "uidValidity?", "uid?", "resourceUri?" } ``` The compact result confirms placement without echoing the subject, recipients, local input paths, or filenames. `create_draft` composes a complete RFC822 -message with Date and Message-ID and appends it to a selectable Drafts mailbox -with the `\Draft` flag. The identity fields are best-effort: async-imap does -not expose UIDPLUS `APPENDUID`, so the server is asked for the generated -Message-ID after APPEND; when that recovery succeeds the response carries the -new draft's nonzero `uid`/`uidValidity` and a UIDVALIDITY-safe `resourceUri`. +message with Date, Message-ID, Apple Mail draft markers, and optional Bcc, +Reply-To, In-Reply-To, and References headers, then appends it to a selectable +Drafts mailbox with the `\Draft` flag. Bcc is deliberately retained in the +stored draft. The identity fields are best-effort: after APPEND the generated +Message-ID is searched when an APPENDUID is unavailable. If APPEND loses its +tagged completion, AgentMail discards that connection and searches the same +Message-ID on a fresh one. It reports success only when the draft is found; +otherwise it directs the caller to inspect Drafts before retrying. + +`create_reply_draft` starts from a live UIDVALIDITY-safe message. It uses the +source Reply-To before From; reply-all adds source To/Cc while excluding the +configured account address and aliases; Bcc is never inferred. It applies one +`Re:` prefix and extends exact RFC threading headers. A source without a +Message-ID still produces a draft but returns `threadingApplied: false` with a +warning. Neither tool sends mail. + +**update_draft** + +```json +{ "updated": true, "account", "draftsMailbox", + "previousUidValidity", "previousUid", + "uidValidity?", "uid?", "resourceUri?" } +``` + +The input is a complete replacement specification, including attachments. +AgentMail verifies the live UIDVALIDITY and `\Draft` flag, preserves the Apple +draft UUID, and requires server-advertised RFC 8508 REPLACE. It never emulates +replacement with APPEND+DELETE because a disconnect can leave duplicates; an +ambiguous REPLACE error instructs the caller to inspect Drafts before retrying. **download_attachments** ```json @@ -493,8 +572,8 @@ new draft's nonzero `uid`/`uidValidity` and a UIDVALIDITY-safe `resourceUri`. The tool fetches the complete message with `BODY.PEEK[]` only after a live UIDVALIDITY check and `RFC822.SIZE` preflight, with a 64 MiB per-message cap. -It writes a private create-new file under `AGENTMAIL_FILE_ROOT`, so neither an -existing file nor a path outside the sandbox can be overwritten. SHA-256 and +It writes a private create-new file under the active session workspace, so +neither an existing file nor a path outside that workspace can be overwritten. SHA-256 and DKIM are computed from the exact saved bytes. DKIM is verified locally against current DNS; an `Authentication-Results` header is not accepted as proof. SPF is omitted because an RFC822 archive lacks the SMTP client IP, HELO, and @@ -513,6 +592,36 @@ JSON manifest. It does not discover or infer thread membership. All UIDs must 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 +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. + +**export_thread_record** + +```json +{ + "recorded": true, "submittable": true, "submissionExplanation", + "account", "purpose", "selectionDigest", "messageCount", + "bundlePath", "pdfPath", "manifestPath", "totalBytes", + "limitations": [] +} +``` + +The call requires the exact digest from `preview_thread_record` plus a +user-supplied purpose explanation. It re-discovers the graph and refuses any +drift or truncation. The no-overwrite private bundle contains a styled, +page-numbered PDF, one exact RFC822 `.eml` for every selected storage identity, +and a JSON manifest with identities, Message-IDs, hashes, metadata, and current +DNS-backed DKIM results. Each source is capped at 64 MiB and the bundle at 512 +MiB. Every source and the PDF are reopened and parsed, hashes are rechecked, +and the manifest is written last and reopened before `recorded` or +`submittable` becomes true. These flags describe packet completeness and +readiness to hand to a recipient; they do not assert authentication, legal +admissibility, or acceptance by any recipient. + **unsubscribe_message** Required action identity and consent: @@ -582,8 +691,8 @@ message ceiling and continues to mutate in 500-UID batches. | # | Tool | Description | Annotations | | --- | -------------- | ------------------------------------------------------------------------------------ | ----------- | -| 30 | `add_flags` | Add flags and/or set Apple Mail `color` (a color-name string; union semantics). Colors: red, orange, yellow, green, blue, purple, gray. | `idempotent` | -| 31 | `remove_flags` | Remove specific flags and/or clear the Apple Mail color with `clearColor: true`. Others preserved. | `idempotent` | +| 36 | `add_flags` | Add flags and/or set Apple Mail `color` (a color-name string; union semantics). Colors: red, orange, yellow, green, blue, purple, gray. | `idempotent` | +| 37 | `remove_flags` | Remove specific flags and/or clear the Apple Mail color with `clearColor: true`. Others preserved. | `idempotent` | #### Output Schemas @@ -611,9 +720,9 @@ Returns the full updated flag set after the operation. 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:** `list_flags`, `find_attachments`, `top_senders`, `top_domains`, `top_subscriptions`, `top_mailing_lists`, `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`, `unsubscribe_message` +**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_by_sender`, `delete_by_domain`, `delete_list_id`, `reconcile_moves`, and `unsubscribe_message`) targeting the same account are serialized — each waits for the previous destructive task to finish before starting. Non-destructive tasks run concurrently without restriction. +**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` @@ -638,12 +747,16 @@ Potentially long mailbox/skipped breakdowns are capped at 50 rows and include five addresses. These caps reduce model-context payloads without discarding destructive-operation counts or audit state. -## Resources (5 templates) +## Resources (6 templates plus account roots) -Single messages are addressable as resources. `resources/list` is intentionally empty — discovery is template-based (`resources/templates/list`), since mailboxes hold thousands of messages. +`resources/list` returns one `email://{account}` root for every configured +account. Reading a root yields its selectable mailbox catalog; reading a +mailbox resource yields a bounded newest-first page of message metadata. This +makes the resource surface navigable while avoiding an unbounded static list. | URI template | MIME type | Content | | ------------------------------------------------------------------------- | --------------------- | -------------------------------------------- | +| `email://{account}/{mailbox}{?offset,limit}` | `application/json` | Paged metadata, default 25 and maximum 50 | | `email://{account}/{mailbox}/{uidValidity}/{uid}` | `text/markdown` | Normalized message body, capped at 100K chars | | `email://{account}/{mailbox}/{uidValidity}/{uid}/headers` | `text/rfc822-headers` | Exact RFC822 header block, maximum 64 KiB | | `email://{account}/{mailbox}/{uidValidity}/{uid}/source` | `message/rfc822` | Lossless base64 MCP blob, maximum 256 KiB | @@ -684,6 +797,13 @@ bytes without lossy UTF-8 conversion. For evidence archives or complete sources that should not traverse model context, use `download_message_source` or `download_thread` instead. +Account roots, mailbox catalogs, and message representations carry MCP +resource annotations. Catalog/body/info resources target the assistant at +priorities appropriate to discovery, headers/source are lower-priority exact +evidence views, and attachment blobs target both user and assistant. Agent Muse +preserves `audience`, `priority`, and `lastModified` when mapping backend +resources and displays them in the MCP Inspector. + **Error codes for `resources/read`:** | Code | Meaning | diff --git a/README.md b/README.md index 6794a81..cef2896 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ --- created: 2026-05-29T19:20 -updated: 2026-08-04T00:00 +updated: 2026-08-23T00:00 --- # agentmail @@ -409,8 +409,8 @@ Opens a web UI to exercise all advertised tools, 6 prompts, and task calls inter ## MCP Tools -31 tools cover account discovery, mailbox management, message reading, search, -bulk operations, recovery, evidence archiving, flag management, and composition. 19 long-running tools support +37 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. @@ -419,6 +419,8 @@ optional [task-based invocation](https://modelcontextprotocol.io/specification/2 | `list_accounts` | Return configured account names (use this first) | | `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 | +| `delete_mailbox` | Preview, then confirm guarded mailbox deletion | | `check_connection` | Test IMAP connectivity for an account | | `list_capabilities` | List IMAP server capabilities (IDLE, MOVE, etc.) | | `get_messages` | Paginated metadata discovery, newest-first, with safe body resource URIs | @@ -433,6 +435,8 @@ optional [task-based invocation](https://modelcontextprotocol.io/specification/2 | `download_attachments` | Download attachments from a message to disk | | `download_message_source` | Save exact RFC822 bytes to disk with SHA-256 and local DKIM verification | | `download_thread` | Save a caller-selected UID set plus a JSON evidence manifest | +| `preview_thread_record` | Discover an exact cross-mailbox Message-ID graph and return its confirmation digest | +| `export_thread_record` | Confirm and export that graph as PDF, exact EML sources, and an integrity manifest | | `delete_messages` | Delete messages by UID (up to 500 per call, moves to Trash or expunges) | | `delete_by_sender` | Delete all messages from an exact sender identity, optionally across all mailboxes | | `delete_by_domain` | Delete messages from one exact canonical sender domain | @@ -443,7 +447,9 @@ optional [task-based invocation](https://modelcontextprotocol.io/specification/2 | `move_subscription` | Move the exact bulk-mail subscription represented by a `top_subscriptions` sample | | `move_message` | Move a message between mailboxes via IMAP MOVE | | `reconcile_moves` | Safely resume one or all pending COPY-fallback MOVE operations | -| `create_draft` | Compose RFC822 draft and append to Drafts folder | +| `create_draft` | Save a draft with To/Cc/Bcc/Reply-To, threading headers, and attachments | +| `create_reply_draft` | Derive reply or reply-all recipients and RFC threading headers from a live message | +| `update_draft` | Atomically replace a draft using RFC 8508 REPLACE; never sends | | `unsubscribe_message` | DKIM-verified RFC 8058 unsubscribe; optional List-Id cleanup is off by default | | `add_flags` | Add flags and/or set Apple Mail color on a message (union semantics) | | `remove_flags` | Remove flags and/or clear Apple Mail color from a message | @@ -497,6 +503,11 @@ optional [task-based invocation](https://modelcontextprotocol.io/specification/2 `delete_by_sender` instead takes the exact sender identity (`email` + `name`, from a ranking row) and confirms it live in each mailbox, so it carries no sample UID or epoch guard. +- `rename_mailbox` and `delete_mailbox` are preview-then-confirm operations. + Both refuse INBOX and any mailbox referenced by pending MOVE recovery. + A changed live message count invalidates confirmation, and special-use or + descendant-bearing mailboxes require separate acknowledgement. Deleting a + non-empty mailbox requires its own acknowledgement as well. - `top_subscriptions` returns a nested `sample` identity, not an unsubscribe URL or raw list-action header. `unsubscribe_message` additionally requires explicit `confirmOneClick=true`. Its `advertisedOneClick` field describes @@ -511,7 +522,7 @@ optional [task-based invocation](https://modelcontextprotocol.io/specification/2 List-Id. The destination mailbox is excluded from the sweep. - The action-time DKIM source fetch is preceded by `RFC822.SIZE`, capped at 64 MiB, and fetched with a bounded IMAP partial. This per-source safety bound does not limit matching-message cleanup counts. - `download_message_source` saves exact RFC822 bytes directly from IMAP to a - private file inside `AGENTMAIL_FILE_ROOT`; the bytes do not cross model + private file inside the active agent session workspace; the bytes do not cross model context. It uses `BODY.PEEK[]`, validates UIDVALIDITY, refuses overwrite, and returns SHA-256, parsed message metadata, and a DNS-backed local DKIM result. `download_thread` applies the same rules to a caller-selected set of up to @@ -519,13 +530,36 @@ optional [task-based invocation](https://modelcontextprotocol.io/specification/2 SPF is omitted because independent SPF evaluation needs delivery-time SMTP 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 + `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 + sessions. +- `preview_thread_record` follows only exact normalized `Message-ID`, + `In-Reply-To`, and `References` relationships across eligible mailboxes; + subject similarity is never evidence of membership. `export_thread_record` + re-runs discovery and requires the exact preview digest, then writes a + private no-overwrite bundle with a styled PDF, one exact `.eml` per selected + storage identity, and a JSON integrity manifest. It reopens, parses, and + hash-checks artifacts before returning `recorded: true` and + `submittable: true`. Those flags mean the packet is complete and can be + handed to a recipient; they make no claim about legal admissibility. - RFC 8058 requests accept exactly one parsed HTTPS URI, reject credentials, fragments, HTTP alternatives, private/link-local/loopback destinations, mixed public/private DNS answers, proxies, retries, and redirects, and require a direct 2xx response. The resolved public addresses are pinned for the request. - Matching-message cleanup is one optional `cleanup {when, identity, deletion}` object; omitting it means unsubscribe only. Defaults are fail-safe: `when: "afterSuccess"` (a failed unsubscribe never triggers cleanup unless `"always"` is explicit), `deletion: "trash"` (never permanent unless `"trashThenPermanent"` or `"permanent"` is explicit). Cleanup matches the normalized RFC 2919 List-Id only when the same passing DKIM signature covered that single List-Id; otherwise `identity: "listIdOrSender"` (default) requires exact normalized sender email plus `List-Unsubscribe-Post`, and also requires the target's normalized List-Id whenever the sampled message has one. Display names never affect this fallback. - Account-wide destructive operations use a separate mutation plan: they enumerate selectable storage mailboxes and never issue writes through `\All`, `\Flagged`, or `\Important` aggregate views. An explicitly supplied mailbox is always honored. - `delete_by_sender`, `delete_list_id`, and unsubscribe matching have no total-message ceiling; server mutations are split into 500-UID wire batches. Only the MCP `delete_messages` tool limits an explicitly supplied UID array to 500 per call. - `search_messages` supports date range (`since`/`before`, YYYY-MM-DD) and size (`larger_than`/`smaller_than`, bytes) for "older than" / "bigger than" cleanup, plus AND-combined case-insensitive substring text filters. `delete_list_id` matches the List-Id exactly (not as a substring). - On Gmail, deletes route through `[Gmail]/Trash` (in-place expunge only removes a label); `permanent` also goes to Trash, which Gmail purges on its own. -- Non-ASCII `search_messages` text is sent with `CHARSET UTF-8`. Drafts include `Date` and `Message-ID` headers. +- 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. `update_draft` requires server-advertised + RFC 8508 REPLACE and deliberately refuses an APPEND+DELETE emulation. A lost + draft-APPEND completion is reconciled on a fresh connection with the + generated Message-ID; an unprovable outcome tells the caller to inspect + Drafts instead of inviting a duplicate-producing retry. - 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. @@ -629,24 +663,46 @@ unchanged destination `UIDNEXT` proves the ambiguous attempt created nothing. | `unsubscribe-cleanup` | Identify lists, obtain consent, then run verified unsubscribe and optional cleanup | | `list-id-cleanup` | Identify mailing lists by List-Id and bulk-delete entire lists | +### Apple Mail draft acceptance (manual) + +After changing draft composition or replacement, validate against a real test +account in Apple Mail: + +1. Create a draft through AgentMail, open it in Apple Mail, click Send, and + confirm the draft disappears and exactly one copy appears in Sent. +2. Create another draft, update it through AgentMail, then repeat the same + open-and-send check. + +This is intentionally a manual outward-facing acceptance test. The automated +suite verifies wire format, threading, Bcc retention, Apple markers, and UUID +preservation without sending mail. + ## MCP Resources -Single messages are addressable as resources — the read-one-message primitive that complements the paginated tools: +`resources/list` exposes one annotated `email://{account}` root per configured +account. Reading it returns selectable mailbox resource URIs; reading a mailbox +URI returns paged newest-first message metadata and canonical message URIs. +Single messages are then addressable without constructing an identity by hand: | URI template | MIME type | Content | | ------------------------------------------------------------------- | --------------------- | ---------------------------------------- | +| `email://{account}/{mailbox}{?offset,limit}` | `application/json` | Paged message metadata, default 25/max 50 | | `email://{account}/{mailbox}/{uidValidity}/{uid}` | `text/markdown` | Normalized body view, capped at 100K chars | | `email://{account}/{mailbox}/{uidValidity}/{uid}/headers` | `text/rfc822-headers` | Exact RFC822 header block, maximum 64 KiB | | `email://{account}/{mailbox}/{uidValidity}/{uid}/source` | `message/rfc822` | Lossless base64 MCP blob, maximum 256 KiB | +| `email://{account}/{mailbox}/{uidValidity}/{uid}/info` | `application/json` | Metadata, sibling URIs, and attachment inventory | +| `email://{account}/{mailbox}/{uidValidity}/{uid}/attachments/{index}` | per MIME part | One attachment blob, maximum 4 MiB | Encoding rules: `account` and `mailbox` are percent-encoded URI segments — a `/` inside a mailbox name must be encoded as `%2F`, for example `email://work/Archive%2F2024/3857529045/1234`. Both UID values must be non-zero. Every read validates the live UIDVALIDITY before fetching; a stale identity is reported as resource-not-found rather than reading a recycled UID. Get current -URIs from `get_messages`, `search_messages`, `find_attachments`, or the -`top_*` tools. `resources/list` is intentionally empty because discovery is -template-based. The `/source` representation uses the MCP resource `blob` +URIs from the account/mailbox resources, `get_messages`, `search_messages`, +`find_attachments`, or the `top_*` tools. Account, mailbox, body, info, header, +source, and attachment resources carry MCP audience/priority annotations so a +host can decide whether to present them to the user, the assistant, or both. +The `/source` representation uses the MCP resource `blob` field, whose value is base64, so arbitrary RFC822 octets are preserved without lossy UTF-8 conversion. @@ -662,7 +718,7 @@ Argument autocompletion (`completion/complete`) is supported for the prompts and ``` agentmail (binary crate: agentmail-mcp) ├── serve → MCP stdio server (tokio + rmcp) - │ 31 tools + 6 prompts, tasks, progress notifications + │ 37 tools + 6 prompts, tasks, progress notifications ├── list-accounts → CLI ├── list-mailboxes → CLI ├── create-mailbox → CLI @@ -686,7 +742,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: 31 tools, 6 prompts, tasks, resources, completions + ├── mcp/ → MCP server: 37 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/assets/fonts/noto-sans/NotoSans-Variable.ttf b/assets/fonts/noto-sans/NotoSans-Variable.ttf new file mode 100644 index 0000000..7557504 Binary files /dev/null and b/assets/fonts/noto-sans/NotoSans-Variable.ttf differ diff --git a/assets/fonts/noto-sans/OFL.txt b/assets/fonts/noto-sans/OFL.txt new file mode 100644 index 0000000..6843f31 --- /dev/null +++ b/assets/fonts/noto-sans/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/draft.rs b/src/draft.rs index 9786888..b74c51d 100644 --- a/src/draft.rs +++ b/src/draft.rs @@ -1,6 +1,14 @@ use lettre::message::header::ContentType; use lettre::message::{Attachment, Mailbox, Message, MultiPart, SinglePart}; +#[derive(Clone, Copy)] +pub(crate) struct DraftHeaderOptions<'a> { + pub(crate) reply_to: &'a [String], + pub(crate) in_reply_to: Option<&'a str>, + pub(crate) references: &'a [String], + pub(crate) apple_uuid: uuid::Uuid, +} + /// Extract the generated Message-ID (without angle brackets) from a composed /// RFC822 message, for locating the stored copy on the server afterwards. pub fn extract_message_id(rfc822: &[u8]) -> Option { @@ -34,11 +42,46 @@ pub fn compose_draft( bcc: &[String], from: Option<&str>, attachments: &[crate::types::DraftAttachment], +) -> crate::Result> { + compose_draft_with_headers( + subject, + body, + to, + cc, + bcc, + from, + attachments, + DraftHeaderOptions { + reply_to: &[], + in_reply_to: None, + references: &[], + apple_uuid: uuid::Uuid::new_v4(), + }, + ) +} + +/// Build a Mail.app-compatible RFC822 draft with optional threading headers. +#[allow(clippy::too_many_arguments)] +pub(crate) fn compose_draft_with_headers( + subject: &str, + body: &str, + to: &[String], + cc: &[String], + bcc: &[String], + from: Option<&str>, + attachments: &[crate::types::DraftAttachment], + headers: DraftHeaderOptions<'_>, ) -> crate::Result> { // `message_id(None)` makes lettre generate a unique `` — // without it drafts ship with no Message-ID (lettre auto-adds Date but // not Message-ID), which breaks threading and trips some spam filters. - let mut builder = Message::builder().subject(subject).message_id(None); + // This is an IMAP-saved draft, not a transport submission. Lettre drops + // Bcc after deriving an SMTP envelope by default; Mail clients need the + // header retained so the recipient survives reopening the draft. + let mut builder = Message::builder() + .subject(subject) + .message_id(None) + .keep_bcc(); if let Some(from_addr) = from { builder = builder.from(parse_mailbox(from_addr)?); @@ -56,6 +99,10 @@ pub fn compose_draft( builder = builder.bcc(parse_mailbox(addr)?); } + for addr in headers.reply_to { + builder = builder.reply_to(parse_mailbox(addr)?); + } + let message = if attachments.is_empty() { builder .body(body.to_string()) @@ -73,7 +120,86 @@ pub fn compose_draft( .map_err(|e| crate::AgentmailError::Other(format!("Failed to build message: {}", e)))? }; - Ok(message.formatted()) + let mut custom_headers = vec![ + "X-Uniform-Type-Identifier: com.apple.mail-draft".to_string(), + "X-Apple-Auto-Saved: 1".to_string(), + format!( + "X-Universally-Unique-Identifier: {}", + headers.apple_uuid.hyphenated() + ), + ]; + if let Some(message_id) = headers.in_reply_to { + custom_headers.push(format!( + "In-Reply-To: {}", + normalize_message_id(message_id)? + )); + } + if !headers.references.is_empty() { + let normalized = headers + .references + .iter() + .map(|message_id| normalize_message_id(message_id)) + .collect::>>()?; + let mut line = format!("References: {}", normalized[0]); + for message_id in normalized.iter().skip(1) { + line.push_str("\r\n\t"); + line.push_str(message_id); + } + if line.len() > 16 * 1024 { + return Err(crate::AgentmailError::Other( + "draft References header exceeds 16 KiB".to_string(), + )); + } + custom_headers.push(line); + } + insert_headers(message.formatted(), &custom_headers) +} + +fn normalize_message_id(value: &str) -> crate::Result { + let value = value.trim(); + if value.is_empty() + || value.contains(['\r', '\n', '\0']) + || !value.is_ascii() + || value.chars().any(char::is_whitespace) + { + return Err(crate::AgentmailError::Other(format!( + "invalid message id '{value}'" + ))); + } + let inner = value + .strip_prefix('<') + .and_then(|value| value.strip_suffix('>')) + .unwrap_or(value); + if inner.is_empty() || inner.contains(['<', '>']) { + return Err(crate::AgentmailError::Other(format!( + "invalid message id '{value}'" + ))); + } + Ok(format!("<{inner}>")) +} + +fn insert_headers(mut rfc822: Vec, headers: &[String]) -> crate::Result> { + let Some(header_end) = rfc822.windows(4).position(|window| window == b"\r\n\r\n") else { + return Err(crate::AgentmailError::Parse( + "composed draft has no RFC822 header boundary".to_string(), + )); + }; + let mut insertion = Vec::new(); + for header in headers { + insertion.extend_from_slice(b"\r\n"); + insertion.extend_from_slice(header.as_bytes()); + } + rfc822.splice(header_end..header_end, insertion); + Ok(rfc822) +} + +pub(crate) fn extract_apple_uuid(rfc822: &[u8]) -> Option { + let parsed = mail_parser::MessageParser::default().parse(rfc822)?; + let value = parsed + .header("X-Universally-Unique-Identifier")? + .as_text()? + .trim(); + uuid::Uuid::parse_str(value.trim_matches(['<', '>'])).ok() } #[cfg(test)] @@ -286,4 +412,61 @@ mod tests { "a composed draft's generated Message-ID must be extractable" ); } + + #[test] + fn extended_draft_carries_reply_threading_bcc_and_mail_app_markers() { + let apple_uuid = + uuid::Uuid::parse_str("019c0000-1234-7000-8000-000000000001").expect("fixed UUID"); + let raw = compose_draft_with_headers( + "Re: status", + "Following up.", + &["to@example.com".to_string()], + &["cc@example.com".to_string()], + &["blind@example.com".to_string()], + Some("me@example.com"), + &[], + DraftHeaderOptions { + reply_to: &["answers@example.com".to_string()], + in_reply_to: Some("parent@example.com"), + references: &[ + "ancestor@example.com".to_string(), + "".to_string(), + ], + apple_uuid, + }, + ) + .expect("compose extended draft"); + let text = String::from_utf8(raw.clone()).expect("ASCII test message"); + let parsed = parse(&raw); + + assert!(text.contains("Bcc: blind@example.com")); + assert!(text.contains("Reply-To: answers@example.com")); + assert!(text.contains("In-Reply-To: ")); + assert!(text.contains("References: \r\n\t")); + assert!(text.contains("X-Uniform-Type-Identifier: com.apple.mail-draft")); + assert!(text.contains("X-Apple-Auto-Saved: 1")); + assert_eq!(extract_apple_uuid(&raw), Some(apple_uuid)); + assert!(parsed.bcc().is_some()); + } + + #[test] + fn threading_headers_reject_injection_before_serialization() { + let error = compose_draft_with_headers( + "subject", + "body", + &["to@example.com".to_string()], + &[], + &[], + Some("me@example.com"), + &[], + DraftHeaderOptions { + reply_to: &[], + in_reply_to: Some("parent@example.com\r\nBcc: attacker@example.com"), + references: &[], + apple_uuid: uuid::Uuid::new_v4(), + }, + ) + .expect_err("header injection must fail"); + assert!(error.to_string().contains("invalid message id")); + } } diff --git a/src/error.rs b/src/error.rs index 5b16eca..9559a3e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -11,6 +11,12 @@ pub enum AgentmailError { #[error("I/O error: {0}")] Io(#[from] std::io::Error), + /// A timed-out IMAP command leaves the connection's parser state unsafe + /// to reuse. For a mutation, it also means the server-side outcome may be + /// unknown even though no tagged completion reached the client. + #[error("IMAP operation timed out after {seconds}s")] + ImapTimeout { seconds: u64 }, + #[error("mutation journal database error: {0}")] JournalSqlite(#[from] rusqlite::Error), @@ -82,7 +88,10 @@ impl AgentmailError { e, async_imap::error::Error::Io(_) | async_imap::error::Error::ConnectionLost ), - AgentmailError::Io(_) | AgentmailError::Tls(_) | AgentmailError::NotConnected => true, + AgentmailError::Io(_) + | AgentmailError::Tls(_) + | AgentmailError::ImapTimeout { .. } + | AgentmailError::NotConnected => true, _ => false, } } @@ -112,6 +121,7 @@ mod tests { .is_connection_error() ); assert!(AgentmailError::NotConnected.is_connection_error()); + assert!(AgentmailError::ImapTimeout { seconds: 90 }.is_connection_error()); // Server rejection / client errors → NOT retryable. assert!( diff --git a/src/imap_client.rs b/src/imap_client.rs index f354b60..5353c32 100644 --- a/src/imap_client.rs +++ b/src/imap_client.rs @@ -7,7 +7,7 @@ use std::time::Duration; use async_imap::Session; use async_imap::extensions::compress::DeflateStream; use futures::StreamExt; -use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt as _}; use tokio::net::TcpStream; use tokio_native_tls::TlsStream; use tracing::{debug, warn}; @@ -266,10 +266,9 @@ where match tokio::time::timeout(limit, future).await { Ok(Ok(val)) => Ok(val), Ok(Err(e)) => Err(e.into()), - Err(_elapsed) => Err(AgentmailError::Other(format!( - "IMAP operation timed out after {}s", - limit.as_secs() - ))), + Err(_elapsed) => Err(AgentmailError::ImapTimeout { + seconds: limit.as_secs(), + }), } } @@ -1191,6 +1190,23 @@ pub async fn create_mailbox(session: &mut ImapSession, mailbox_name: &str) -> Re Ok(()) } +/// Rename one mailbox. The caller owns preflight, mutation serialization, and +/// post-error reconciliation because a lost tagged response is ambiguous. +pub async fn rename_mailbox( + session: &mut ImapSession, + mailbox_name: &str, + new_mailbox_name: &str, +) -> Result<()> { + imap_timeout(session.rename(mailbox_name, new_mailbox_name)).await?; + Ok(()) +} + +/// Delete one mailbox. The caller owns guarded confirmation and reconciliation. +pub async fn delete_mailbox(session: &mut ImapSession, mailbox_name: &str) -> Result<()> { + imap_timeout(session.delete(mailbox_name)).await?; + Ok(()) +} + // --------------------------------------------------------------------------- // Fetch messages // --------------------------------------------------------------------------- @@ -3069,6 +3085,124 @@ pub async fn append_draft( Ok(()) } +fn append_uid_identity( + uid_validity: u32, + members: &[async_imap::imap_proto::UidSetMember], +) -> Result> { + let uids = expand_uid_members(members)?; + match uids.as_slice() { + [uid] => Ok(Some((uid_validity, *uid))), + [] => Ok(None), + _ => Err(AgentmailError::Parse( + "server returned more than one UID for a single-message REPLACE".to_string(), + )), + } +} + +fn quote_imap_string(value: &str) -> Result { + if value.contains(['\r', '\n', '\0']) { + return Err(AgentmailError::Other( + "mailbox name contains an invalid control character".to_string(), + )); + } + Ok(format!( + "\"{}\"", + value.replace('\\', "\\\\").replace('"', "\\\"") + )) +} + +/// Atomically replace a draft using RFC 8508 UID REPLACE. +/// +/// The caller must verify the `REPLACE` capability and the target's `\Draft` +/// flag before invoking this function. No APPEND+DELETE fallback is provided: +/// that sequence is observably non-atomic and can leave duplicate drafts. +pub async fn replace_draft( + session: &mut ImapSession, + drafts_mailbox: &str, + uid: u32, + expected_uid_validity: u32, + rfc822_message: &[u8], +) -> Result> { + use async_imap::imap_proto::{Response, ResponseCode, Status}; + + select_with_expected_uid_validity(session, drafts_mailbox, expected_uid_validity).await?; + let mailbox = quote_imap_string(drafts_mailbox)?; + let command = format!( + "UID REPLACE {uid} {mailbox} (\\Draft) {{{}}}", + rfc822_message.len() + ); + let tag = imap_timeout(session.run_command(command)).await?; + + loop { + let response = imap_timeout(session.read_response()) + .await? + .ok_or(AgentmailError::NotConnected)?; + match response.parsed() { + Response::Continue { .. } => break, + Response::Done { + tag: done, + status, + information, + .. + } if done == &tag => { + return Err(match status { + Status::No => AgentmailError::Imap(async_imap::error::Error::No(format!( + "REPLACE rejected before literal: {information:?}" + ))), + Status::Bad => AgentmailError::Imap(async_imap::error::Error::Bad(format!( + "REPLACE rejected before literal: {information:?}" + ))), + other => AgentmailError::Other(format!( + "REPLACE ended before literal with {other:?}: {information:?}" + )), + }); + } + _ => {} + } + } + + imap_timeout(session.get_mut().write_all(rfc822_message)).await?; + imap_timeout(session.get_mut().write_all(b"\r\n")).await?; + imap_timeout(session.get_mut().flush()).await?; + + let mut identity = None; + loop { + let response = imap_timeout(session.read_response()) + .await? + .ok_or(AgentmailError::NotConnected)?; + match response.parsed() { + Response::Data { + status: Status::Ok, + code: Some(ResponseCode::AppendUid(uid_validity, members)), + .. + } => identity = append_uid_identity(*uid_validity, members)?, + Response::Done { + tag: done, + status, + code, + information, + } if done == &tag => { + if let Some(ResponseCode::AppendUid(uid_validity, members)) = code { + identity = append_uid_identity(*uid_validity, members)?; + } + return match status { + Status::Ok => Ok(identity), + Status::No => Err(AgentmailError::Imap(async_imap::error::Error::No(format!( + "REPLACE rejected: code={code:?}, info={information:?}" + )))), + Status::Bad => Err(AgentmailError::Imap(async_imap::error::Error::Bad( + format!("REPLACE rejected: code={code:?}, info={information:?}"), + ))), + other => Err(AgentmailError::Other(format!( + "REPLACE completed with unexpected status {other:?}: code={code:?}, info={information:?}" + ))), + }; + } + _ => {} + } + } +} + /// Locate a just-APPENDed message by Message-ID, returning the mailbox /// UIDVALIDITY and the matching UID (the highest, should duplicates exist). /// Used to recover a new draft's identity, since async-imap does not expose diff --git a/src/lib.rs b/src/lib.rs index 9dff660..5b4345b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod imap_client; pub mod mcp; pub mod parser; pub mod provider; +pub(crate) mod record; pub mod scan_cache; pub mod secret; pub mod types; @@ -42,6 +43,8 @@ const MAX_PAGE_OFFSET: usize = 1_000_000; const MAX_PAGE_LIMIT: usize = 100; 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; /// High-level facade for IMAP operations. /// Owns the connection pool and configuration. @@ -616,7 +619,10 @@ impl Agentmail { // Check if mailbox already exists (make CREATE idempotent) let names = imap_client::list_mailbox_names(session.session()).await?; - if names.iter().any(|n| n.eq_ignore_ascii_case(mailbox_name)) { + if names + .iter() + .any(|name| mailbox_names_equal(name, mailbox_name)) + { session.release().await; self.invalidate_mailbox_catalog(account); return Ok(CreateMailboxResponse { @@ -645,6 +651,207 @@ impl Agentmail { }) } + /// Preview or perform a guarded mailbox rename. + #[allow(clippy::too_many_arguments)] + pub async fn rename_mailbox( + &self, + account: &str, + mailbox_name: &str, + new_mailbox_name: &str, + confirm_rename: bool, + expected_message_count: Option, + confirm_special_use: bool, + confirm_descendants: bool, + ) -> Result { + let _mutation_guard = self.lock_account_mutation(account).await; + let mut session = self.pool.acquire(account).await?; + let layout = imap_client::list_mailbox_layout(session.session()).await?; + let entry = find_mailbox_layout(&layout, mailbox_name) + .ok_or_else(|| AgentmailError::MailboxNotFound(mailbox_name.to_string()))?; + if mailbox_names_equal(&entry.path, "INBOX") { + return Err(AgentmailError::Other( + "INBOX cannot be renamed through AgentMail".to_string(), + )); + } + if find_mailbox_layout(&layout, new_mailbox_name).is_some() { + return Err(AgentmailError::Other(format!( + "destination mailbox '{new_mailbox_name}' already exists" + ))); + } + self.ensure_mailbox_not_in_pending_move(account, entry) + .await?; + let preflight = mailbox_mutation_preflight( + session.session(), + entry, + &layout, + MailboxMutationKind::Rename, + ) + .await?; + if !confirm_rename { + session.release().await; + return Ok(RenameMailboxResponse { + account: account.to_string(), + mailbox: entry.path.clone(), + new_mailbox: new_mailbox_name.to_string(), + preview: true, + renamed: false, + preflight, + }); + } + require_expected_message_count(expected_message_count, preflight.message_count)?; + if !preflight.roles.is_empty() && !confirm_special_use { + return Err(AgentmailError::Other( + "mailbox has special-use roles; repeat with confirmSpecialUse=true".to_string(), + )); + } + if !preflight.descendants.is_empty() && !confirm_descendants { + return Err(AgentmailError::Other( + "mailbox has descendants; repeat with confirmDescendants=true".to_string(), + )); + } + + self.fence_header_cache_mutation(account).await; + self.invalidate_mailbox_catalog(account); + let rename_result = + imap_client::rename_mailbox(session.session(), &entry.path, new_mailbox_name).await; + self.invalidate_mailbox_catalog(account); + let renamed = match rename_result { + Ok(()) => true, + Err(error) if error.is_connection_error() => { + drop(session); + let (fresh_session, refreshed) = self + .mailbox_layout_after_ambiguous_mutation(account, "rename", &error) + .await?; + session = fresh_session; + let old_exists = find_mailbox_layout(&refreshed, &entry.path).is_some(); + let new_exists = find_mailbox_layout(&refreshed, new_mailbox_name).is_some(); + if !old_exists && new_exists { + true + } else if old_exists && !new_exists { + return Err(error); + } else { + return Err(AgentmailError::Other(format!( + "rename outcome is ambiguous after transport failure: oldExists={old_exists}, newExists={new_exists}; inspect list_mailboxes before retrying" + ))); + } + } + Err(error) => return Err(error), + }; + self.fence_header_cache_mutation(account).await; + self.invalidate_mailbox_catalog(account); + session.release().await; + Ok(RenameMailboxResponse { + account: account.to_string(), + mailbox: entry.path.clone(), + new_mailbox: new_mailbox_name.to_string(), + preview: false, + renamed, + preflight, + }) + } + + /// Preview or perform a guarded mailbox delete. + #[allow(clippy::too_many_arguments)] + pub async fn delete_mailbox( + &self, + account: &str, + mailbox_name: &str, + confirm_delete: bool, + expected_message_count: Option, + confirm_non_empty: bool, + confirm_special_use: bool, + confirm_descendants: bool, + ) -> Result { + let _mutation_guard = self.lock_account_mutation(account).await; + let mut session = self.pool.acquire(account).await?; + let layout = imap_client::list_mailbox_layout(session.session()).await?; + let Some(entry) = find_mailbox_layout(&layout, mailbox_name) else { + session.release().await; + self.invalidate_mailbox_catalog(account); + return Ok(DeleteMailboxResponse { + account: account.to_string(), + mailbox: mailbox_name.to_string(), + preview: false, + deleted: false, + already_missing: true, + preflight: None, + }); + }; + if mailbox_names_equal(&entry.path, "INBOX") { + return Err(AgentmailError::Other( + "INBOX cannot be deleted through AgentMail".to_string(), + )); + } + self.ensure_mailbox_not_in_pending_move(account, entry) + .await?; + let preflight = mailbox_mutation_preflight( + session.session(), + entry, + &layout, + MailboxMutationKind::Delete, + ) + .await?; + if !confirm_delete { + session.release().await; + return Ok(DeleteMailboxResponse { + account: account.to_string(), + mailbox: entry.path.clone(), + preview: true, + deleted: false, + already_missing: false, + preflight: Some(preflight), + }); + } + require_expected_message_count(expected_message_count, preflight.message_count)?; + if preflight.message_count > 0 && !confirm_non_empty { + return Err(AgentmailError::Other( + "mailbox is non-empty; repeat with confirmNonEmpty=true".to_string(), + )); + } + if !preflight.roles.is_empty() && !confirm_special_use { + return Err(AgentmailError::Other( + "mailbox has special-use roles; repeat with confirmSpecialUse=true".to_string(), + )); + } + if !preflight.descendants.is_empty() && !confirm_descendants { + return Err(AgentmailError::Other( + "mailbox has descendants; repeat with confirmDescendants=true".to_string(), + )); + } + + self.fence_header_cache_mutation(account).await; + self.invalidate_mailbox_catalog(account); + let delete_result = imap_client::delete_mailbox(session.session(), &entry.path).await; + self.invalidate_mailbox_catalog(account); + let deleted = match delete_result { + Ok(()) => true, + Err(error) if error.is_connection_error() => { + drop(session); + let (fresh_session, refreshed) = self + .mailbox_layout_after_ambiguous_mutation(account, "delete", &error) + .await?; + session = fresh_session; + if find_mailbox_layout(&refreshed, &entry.path).is_none() { + true + } else { + return Err(error); + } + } + Err(error) => return Err(error), + }; + self.fence_header_cache_mutation(account).await; + self.invalidate_mailbox_catalog(account); + session.release().await; + Ok(DeleteMailboxResponse { + account: account.to_string(), + mailbox: entry.path.clone(), + preview: false, + deleted, + already_missing: false, + preflight: Some(preflight), + }) + } + // ----------------------------------------------------------------- // Messages // ----------------------------------------------------------------- @@ -3028,7 +3235,7 @@ impl Agentmail { return Err(AgentmailError::Other("destination is required".to_string())); } if let Some(mbox) = mailbox - && mbox.eq_ignore_ascii_case(destination) + && mailbox_names_equal(mbox, destination) { return Err(AgentmailError::Other( "destination equals the source mailbox; nothing to move".to_string(), @@ -3047,7 +3254,7 @@ impl Agentmail { let names = imap_client::list_mailbox_names(session.session()).await?; if !names .iter() - .any(|name| name.eq_ignore_ascii_case(destination)) + .any(|name| mailbox_names_equal(name, destination)) { session.release().await; return Err(AgentmailError::Other(format!( @@ -3067,7 +3274,7 @@ impl Agentmail { // Never sweep the destination itself: the just-moved messages // still match the selector and would be "moved" onto // themselves pass after pass. - .filter(|mbox| !mbox.eq_ignore_ascii_case(destination)) + .filter(|mbox| !mailbox_names_equal(mbox, destination)) .collect(), }; self.matching_sweep( @@ -3468,7 +3675,10 @@ impl Agentmail { // Validate destination mailbox exists let names = imap_client::list_mailbox_names(session.session()).await?; - if !names.iter().any(|n| n.eq_ignore_ascii_case(destination)) { + if !names + .iter() + .any(|name| mailbox_names_equal(name, destination)) + { session.release().await; return Err(AgentmailError::Other(format!( "Destination mailbox '{}' does not exist", @@ -3533,63 +3743,64 @@ impl Agentmail { bcc: &[String], attachments: &[crate::types::DraftAttachment], ) -> Result { - if to.is_empty() && cc.is_empty() && bcc.is_empty() { - return Err(AgentmailError::Other( - "At least one recipient (to, cc, or bcc) is required".to_string(), - )); - } - let recipient_count = to - .len() - .checked_add(cc.len()) - .and_then(|count| count.checked_add(bcc.len())) - .ok_or_else(|| AgentmailError::Other("draft recipient count overflow".to_string()))?; - if recipient_count > MAX_DRAFT_RECIPIENTS { - return Err(AgentmailError::Other(format!( - "draft has {recipient_count} recipients; maximum is {MAX_DRAFT_RECIPIENTS}" - ))); - } - if body.len() > MAX_DRAFT_BODY_BYTES { - return Err(AgentmailError::Other(format!( - "draft body is {} bytes; maximum is {MAX_DRAFT_BODY_BYTES}", - body.len() - ))); - } - if attachments.len() > MAX_DRAFT_ATTACHMENTS { - return Err(AgentmailError::Other(format!( - "draft has {} attachments; maximum is {MAX_DRAFT_ATTACHMENTS}", - attachments.len() - ))); - } - let mut attachment_bytes = 0usize; - for attachment in attachments { - if attachment.data.len() > MAX_DRAFT_ATTACHMENT_BYTES { - return Err(AgentmailError::Other(format!( - "draft attachment '{}' is {} bytes; per-file maximum is {MAX_DRAFT_ATTACHMENT_BYTES}", - attachment.filename, - attachment.data.len() - ))); - } - attachment_bytes = attachment_bytes - .checked_add(attachment.data.len()) - .ok_or_else(|| { - AgentmailError::Other("draft attachment size overflow".to_string()) - })?; - } - if attachment_bytes > MAX_DRAFT_ATTACHMENTS_TOTAL_BYTES { - return Err(AgentmailError::Other(format!( - "draft attachments total {attachment_bytes} bytes; maximum is {MAX_DRAFT_ATTACHMENTS_TOTAL_BYTES}" - ))); - } + self.create_draft_with_headers( + account, + subject, + body, + to, + cc, + bcc, + &[], + None, + &[], + attachments, + None, + ) + .await + } + + /// Create a draft with Reply-To and RFC threading headers. + #[allow(clippy::too_many_arguments)] + pub async fn create_draft_with_headers( + &self, + account: &str, + 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, + ) -> Result { + validate_draft_payload(body, to, cc, bcc, reply_to, attachments)?; let _mutation_guard = self.lock_account_mutation(account).await; - let acct_config = self + let account_config = self .pool .account_config(account) .ok_or_else(|| AgentmailError::AccountNotFound(account.to_string()))?; - let from = &acct_config.username; - - let rfc822 = draft::compose_draft(subject, body, to, cc, bcc, Some(from), attachments)?; + let from = account_config + .canonical_email() + .unwrap_or_else(|| account_config.username.clone()); + let rfc822 = draft::compose_draft_with_headers( + subject, + body, + to, + cc, + bcc, + Some(&from), + attachments, + draft::DraftHeaderOptions { + reply_to, + in_reply_to, + references, + apple_uuid: uuid::Uuid::new_v4(), + }, + )?; if rfc822.len() > MAX_DRAFT_MIME_BYTES { return Err(AgentmailError::Other(format!( "composed draft is {} bytes; maximum is {MAX_DRAFT_MIME_BYTES}", @@ -3612,26 +3823,11 @@ impl Agentmail { .is_ok(); self.invalidate_mailbox_catalog(account); - let append_result = - imap_client::append_draft(session.session(), &drafts_name, &rfc822).await; + let identity = self + .append_draft_with_recovery(account, &drafts_name, &rfc822, session) + .await; self.invalidate_mailbox_catalog(account); - append_result?; - imap_client::sync(session.session()).await?; - - // Best-effort identity recovery: async-imap does not expose UIDPLUS - // APPENDUID, so search the drafts mailbox for the Message-ID that - // compose_draft generated. A recovery failure leaves the identity - // fields unset without failing the successful create. - let identity = match draft::extract_message_id(&rfc822) { - Some(message_id) => { - imap_client::find_uid_by_message_id(session.session(), &drafts_name, &message_id) - .await - .ok() - .flatten() - } - None => None, - }; - session.release().await; + let identity = identity?; let attached_names: Vec = attachments.iter().map(|a| a.filename.clone()).collect(); @@ -3644,13 +3840,255 @@ impl Agentmail { to: to.to_vec(), cc: cc.to_vec(), bcc: bcc.to_vec(), + reply_to: reply_to.to_vec(), }, + in_reply_to: in_reply_to.map(str::to_string), + references: references.to_vec(), + threading_applied: in_reply_to.is_some(), + warning, attachments: attached_names, uid_validity: identity.map(|(uid_validity, _)| uid_validity), uid: identity.map(|(_, uid)| uid), }) } + /// Create a reply or reply-all draft from one live message identity. + #[allow(clippy::too_many_arguments)] + pub async fn create_reply_draft( + &self, + account: &str, + mailbox: &str, + uid: u32, + expected_uid_validity: u32, + mode: ReplyMode, + subject: Option<&str>, + body: &str, + bcc: &[String], + reply_to: &[String], + attachments: &[DraftAttachment], + ) -> Result { + let response = self + .get_messages_by_uid( + mailbox, + account, + &[uid], + expected_uid_validity, + false, + false, + ) + .await?; + let source = response + .messages + .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() { + return Err(AgentmailError::Other( + "the source message has no reply recipient outside this account".to_string(), + )); + } + + 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( + account, + &subject, + body, + &to, + &cc, + bcc, + reply_to, + in_reply_to.as_deref(), + &references, + attachments, + warning, + ) + .await + } + + /// Atomically replace one live `\Draft` using RFC 8508 UID REPLACE. + #[allow(clippy::too_many_arguments)] + pub async fn update_draft( + &self, + account: &str, + drafts_mailbox: &str, + uid: u32, + expected_uid_validity: u32, + subject: &str, + body: &str, + to: &[String], + cc: &[String], + bcc: &[String], + reply_to: &[String], + in_reply_to: Option<&str>, + references: &[String], + attachments: &[DraftAttachment], + ) -> 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 mut session = self.pool.acquire(account).await?; + let caps = self.pool.server_caps(account, session.session()).await?; + if !caps.has("REPLACE") { + session.release().await; + return Err(AgentmailError::Other( + "server does not advertise RFC 8508 REPLACE; refusing a non-atomic APPEND+DELETE fallback" + .to_string(), + )); + } + + imap_client::examine_with_expected_uid_validity( + session.session(), + drafts_mailbox, + expected_uid_validity, + ) + .await?; + let current = imap_client::fetch_by_uids( + session.session(), + &[uid], + drafts_mailbox, + account, + false, + false, + ) + .await? + .into_iter() + .next() + .ok_or(AgentmailError::MessageNotFound(uid))?; + if !current + .flags + .iter() + .any(|flag| flag.eq_ignore_ascii_case("\\Draft")) + { + return Err(AgentmailError::Other(format!( + "mailbox '{drafts_mailbox}' UID {uid} is not marked \\Draft; refusing replacement" + ))); + } + let current_source = imap_client::get_message_source_bounded( + session.session(), + drafts_mailbox, + uid, + expected_uid_validity, + MAX_DRAFT_MIME_BYTES, + ) + .await?; + let apple_uuid = + draft::extract_apple_uuid(¤t_source).unwrap_or_else(uuid::Uuid::new_v4); + let replacement = draft::compose_draft_with_headers( + subject, + body, + to, + cc, + bcc, + Some(&from), + attachments, + draft::DraftHeaderOptions { + reply_to, + in_reply_to, + references, + apple_uuid, + }, + )?; + if replacement.len() > MAX_DRAFT_MIME_BYTES { + return Err(AgentmailError::Other(format!( + "composed draft is {} bytes; maximum is {MAX_DRAFT_MIME_BYTES}", + replacement.len() + ))); + } + + self.fence_header_cache_mutation(account).await; + self.invalidate_mailbox_catalog(account); + let identity = match imap_client::replace_draft( + session.session(), + drafts_mailbox, + uid, + expected_uid_validity, + &replacement, + ) + .await + { + Ok(identity) => identity, + Err(error) if error.is_connection_error() => { + return Err(AgentmailError::Other(format!( + "draft replacement outcome is ambiguous after the IMAP connection failed; inspect the Drafts mailbox before retrying: {error}" + ))); + } + Err(error) => return Err(error), + }; + let identity = match (identity, draft::extract_message_id(&replacement)) { + (Some(identity), _) => Some(identity), + (None, Some(message_id)) => { + imap_client::find_uid_by_message_id(session.session(), drafts_mailbox, &message_id) + .await + .ok() + .flatten() + } + (None, None) => None, + }; + self.fence_header_cache_mutation(account).await; + self.invalidate_mailbox_catalog(account); + session.release().await; + Ok(UpdateDraftResponse { + updated: true, + account: account.to_string(), + drafts_mailbox: drafts_mailbox.to_string(), + previous_uid_validity: expected_uid_validity, + previous_uid: uid, + uid_validity: identity.map(|(uid_validity, _)| uid_validity), + uid: identity.map(|(_, uid)| uid), + }) + } + // ----------------------------------------------------------------- // Raw source // ----------------------------------------------------------------- @@ -3853,6 +4291,231 @@ impl Agentmail { Ok(String::from_utf8_lossy(&headers).into_owned()) } + /// Discover the bounded, exact RFC Message-ID graph around one live + /// message. Subject similarity is intentionally never used: every + /// selected identity must match `Message-ID`, `In-Reply-To`, or one token + /// in `References` exactly after harmless angle-bracket normalization. + pub async fn preview_thread_record( + &self, + mailbox: &str, + account: &str, + uid: u32, + expected_uid_validity: u32, + on_progress: Option<&ProgressFn>, + cancel: Option<&CancelFn>, + ) -> Result { + Self::validate_uid_selector(mailbox, expected_uid_validity, &[uid])?; + let mut session = self.pool.acquire(account).await?; + imap_client::check_cancel(cancel)?; + let seed_headers = imap_client::get_message_headers_bounded( + session.session(), + mailbox, + uid, + expected_uid_validity, + MAX_THREAD_RECORD_HEADER_BYTES, + ) + .await?; + let seed_info = parser::parse_rfc822( + &seed_headers, + uid, + Vec::new(), + u32::try_from(seed_headers.len()).ok(), + mailbox, + account, + false, + false, + )?; + let seed_identity = MailboxMessageIdentity { + mailbox: mailbox.to_string(), + uid_validity: expected_uid_validity, + uid, + }; + let seed = thread_record_message( + seed_identity.clone(), + seed_info, + vec!["seed identity supplied by the caller".to_string()], + ); + + let mailboxes = self + .account_scan_mailboxes( + account, + session.session(), + scan_plan::ScanPurpose::Discovery, + ) + .await?; + let mut messages = vec![seed.clone()]; + let mut seen_messages: hashbrown::HashSet<(String, u32, u32)> = hashbrown::HashSet::new(); + seen_messages.insert(thread_identity_key(&seed.identity)); + let mut known_ids = hashbrown::HashSet::new(); + let mut pending_ids = std::collections::VecDeque::new(); + queue_thread_ids(&seed, &mut known_ids, &mut pending_ids); + let mut warnings = Vec::new(); + let mut truncated = false; + + 'graph: while let Some(query_id) = pending_ids.pop_front() { + imap_client::check_cancel(cancel)?; + for candidate_mailbox in &mailboxes { + imap_client::check_cancel(cancel)?; + let selected = match imap_client::examine(session.session(), candidate_mailbox) + .await + { + Ok(selected) => selected, + Err(error) => { + push_record_warning( + &mut warnings, + format!( + "skipped mailbox '{candidate_mailbox}' during thread discovery: {error}" + ), + ); + continue; + } + }; + let candidate_uid_validity = match imap_client::require_uid_validity( + candidate_mailbox, + selected.uid_validity, + ) { + Ok(uid_validity) => uid_validity, + Err(error) => { + push_record_warning( + &mut warnings, + format!( + "skipped mailbox '{candidate_mailbox}' during thread discovery: {error}" + ), + ); + continue; + } + }; + let mut candidate_uids = hashbrown::HashSet::new(); + let mut search_failed = false; + for header in ["Message-ID", "In-Reply-To", "References"] { + match imap_client::search_by_header( + session.session(), + header, + thread_search_value(&query_id), + ) + .await + { + Ok(uids) => candidate_uids.extend(uids), + Err(error) => { + push_record_warning( + &mut warnings, + format!( + "skipped mailbox '{candidate_mailbox}' query for {header}: {error}" + ), + ); + search_failed = true; + break; + } + } + } + if search_failed { + continue; + } + + let mut candidate_uids = candidate_uids.into_iter().collect::>(); + candidate_uids.sort_unstable(); + for candidate_uid in candidate_uids { + let identity = MailboxMessageIdentity { + mailbox: candidate_mailbox.clone(), + uid_validity: candidate_uid_validity, + uid: candidate_uid, + }; + if seen_messages.contains(&thread_identity_key(&identity)) { + continue; + } + let headers = match imap_client::get_message_headers_bounded( + session.session(), + candidate_mailbox, + candidate_uid, + candidate_uid_validity, + MAX_THREAD_RECORD_HEADER_BYTES, + ) + .await + { + Ok(headers) => headers, + Err(error) => { + push_record_warning( + &mut warnings, + format!( + "skipped {candidate_mailbox} UID {candidate_uid} during exact-header confirmation: {error}" + ), + ); + continue; + } + }; + let info = match parser::parse_rfc822( + &headers, + candidate_uid, + Vec::new(), + u32::try_from(headers.len()).ok(), + candidate_mailbox, + account, + false, + false, + ) { + Ok(info) => info, + Err(error) => { + push_record_warning( + &mut warnings, + format!( + "skipped {candidate_mailbox} UID {candidate_uid} because its headers could not be parsed: {error}" + ), + ); + continue; + } + }; + let basis = thread_match_basis(&info, &query_id); + if basis.is_empty() { + continue; + } + if messages.len() == MAX_THREAD_RECORD_MESSAGES { + truncated = true; + break 'graph; + } + let message = thread_record_message(identity.clone(), info, basis); + seen_messages.insert(thread_identity_key(&identity)); + queue_thread_ids(&message, &mut known_ids, &mut pending_ids); + messages.push(message); + if let Some(progress) = on_progress { + progress(messages.len() as u64, MAX_THREAD_RECORD_MESSAGES as u64); + } + } + } + } + session.release().await; + + messages.sort_by(|left, right| { + left.date + .cmp(&right.date) + .then_with(|| left.identity.mailbox.cmp(&right.identity.mailbox)) + .then_with(|| left.identity.uid.cmp(&right.identity.uid)) + }); + if known_ids.is_empty() { + warnings.push( + "the seed has no Message-ID, In-Reply-To, or References values; the exact graph contains only the seed identity" + .to_string(), + ); + } + if truncated { + warnings.push(format!( + "the exact header graph exceeded {MAX_THREAD_RECORD_MESSAGES} storage identities; export is blocked until the selection is narrowed" + )); + } + let selection_digest = thread_selection_digest(account, &seed_identity, &messages)?; + Ok(ThreadRecordPreviewResponse { + account: account.to_string(), + seed: seed_identity, + strategy: "exact-rfc-message-id-graph".to_string(), + rationale: "Selected only live storage identities connected by exact Message-ID, In-Reply-To, or References values; subject similarity was not used." + .to_string(), + messages, + selection_digest, + confirmation_required: true, + truncated, + warnings, + }) + } + // ----------------------------------------------------------------- // Download attachments // ----------------------------------------------------------------- @@ -4226,6 +4889,138 @@ impl Agentmail { Ok(plan.mailboxes) } + async fn ensure_mailbox_not_in_pending_move( + &self, + account: &str, + mailbox: &imap_client::MailboxLayout, + ) -> Result<()> { + if !self.mutation_journal.is_persistent() { + return Ok(()); + } + let account_key = self.mutation_account_key(account)?; + let pending = self.mutation_journal.list_pending(&account_key).await?; + let delimiter = mailbox.delimiter.as_deref(); + if let Some(operation) = pending.into_iter().find(|operation| { + mailbox_is_same_or_descendant(&operation.source_mailbox, &mailbox.path, delimiter) + || 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.path, operation.operation_id + ))); + } + Ok(()) + } + + /// Reconnect after a mailbox command lost its tagged completion. The old + /// connection is never reused: after EOF or timeout its parser may still + /// be waiting for bytes from the abandoned command. + async fn mailbox_layout_after_ambiguous_mutation( + &self, + account: &str, + operation: &str, + original_error: &AgentmailError, + ) -> Result<(connection::PooledSession, Vec)> { + let mut session = self.pool.acquire(account).await.map_err(|error| { + AgentmailError::Other(format!( + "{operation} outcome is ambiguous after '{original_error}', and a fresh connection for reconciliation failed: {error}; inspect list_mailboxes before retrying" + )) + })?; + let layout = imap_client::list_mailbox_layout(session.session()) + .await + .map_err(|error| { + AgentmailError::Other(format!( + "{operation} outcome is ambiguous after '{original_error}', and a fresh mailbox listing failed: {error}; inspect list_mailboxes before retrying" + )) + })?; + Ok((session, layout)) + } + + /// APPEND one uniquely identified draft and reconcile a lost completion on + /// a fresh connection. A generated Message-ID is the idempotency key: its + /// presence proves the ambiguous APPEND reached the mailbox, while a + /// failed recovery remains explicitly ambiguous instead of inviting a + /// duplicate-producing blind retry. + async fn append_draft_with_recovery( + &self, + account: &str, + drafts_mailbox: &str, + rfc822: &[u8], + mut session: connection::PooledSession, + ) -> Result> { + let message_id = draft::extract_message_id(rfc822); + match imap_client::append_draft(session.session(), drafts_mailbox, rfc822).await { + Ok(()) => { + let identity = match message_id { + Some(message_id) => { + imap_client::find_uid_by_message_id( + session.session(), + drafts_mailbox, + &message_id, + ) + .await + } + None => Ok(None), + }; + match identity { + Ok(identity) => { + session.release().await; + Ok(identity) + } + Err(error) => { + // APPEND already returned tagged OK, so identity lookup + // is best-effort. Never put a timed-out parser back in + // the pool merely because the draft itself succeeded. + tracing::warn!( + target: "agentmail", + account, + mailbox = drafts_mailbox, + error = %error, + "draft was appended, but its UID identity could not be recovered" + ); + drop(session); + Ok(None) + } + } + } + Err(error) if error.is_connection_error() => { + drop(session); + let Some(message_id) = message_id else { + return Err(AgentmailError::Other(format!( + "draft APPEND outcome is ambiguous after {error}; the generated message had no recoverable Message-ID, so inspect Drafts before retrying" + ))); + }; + let mut fresh = self.pool.acquire(account).await.map_err(|recovery_error| { + AgentmailError::Other(format!( + "draft APPEND outcome is ambiguous after {error}, and a fresh recovery connection failed: {recovery_error}; inspect Drafts before retrying" + )) + })?; + match imap_client::find_uid_by_message_id( + fresh.session(), + drafts_mailbox, + &message_id, + ) + .await + { + Ok(Some(identity)) => { + fresh.release().await; + Ok(Some(identity)) + } + Ok(None) => { + fresh.release().await; + Err(AgentmailError::Other(format!( + "draft APPEND outcome is ambiguous after {error}; a fresh Message-ID search found no matching draft, but inspect Drafts before retrying" + ))) + } + Err(recovery_error) => Err(AgentmailError::Other(format!( + "draft APPEND outcome is ambiguous after {error}, and Message-ID recovery failed: {recovery_error}; inspect Drafts before retrying" + ))), + } + } + Err(error) => Err(error), + } + } + /// Resolve this account's Trash and Drafts mailbox names from the bounded /// mailbox-layout catalog. One cold `LIST` resolves both roles. async fn special_mailboxes( @@ -4295,6 +5090,329 @@ impl Agentmail { // Utility functions // --------------------------------------------------------------------------- +#[derive(Debug, Clone, Copy)] +enum MailboxMutationKind { + Rename, + Delete, +} + +fn normalize_thread_message_id(value: &str) -> Option { + let value = value.trim(); + let inner = value + .strip_prefix('<') + .and_then(|value| value.strip_suffix('>')) + .unwrap_or(value) + .trim(); + if inner.is_empty() + || !inner.is_ascii() + || inner + .chars() + .any(|character| character.is_ascii_control() || character.is_ascii_whitespace()) + { + return None; + } + Some(format!("<{inner}>")) +} + +fn thread_search_value(message_id: &str) -> &str { + message_id + .strip_prefix('<') + .and_then(|value| value.strip_suffix('>')) + .unwrap_or(message_id) +} + +fn thread_identity_key(identity: &MailboxMessageIdentity) -> (String, u32, u32) { + ( + identity.mailbox.clone(), + identity.uid_validity, + identity.uid, + ) +} + +fn thread_record_message( + identity: MailboxMessageIdentity, + info: MessageInfo, + selection_basis: Vec, +) -> ThreadRecordMessage { + ThreadRecordMessage { + identity, + message_id: info.message_id, + in_reply_to: info.in_reply_to, + references: info.references, + date: info.date, + from: info.sender, + subject: info.subject, + selection_basis, + } +} + +fn queue_thread_ids( + message: &ThreadRecordMessage, + known_ids: &mut hashbrown::HashSet, + pending_ids: &mut std::collections::VecDeque, +) { + for value in message + .message_id + .iter() + .chain(message.in_reply_to.iter()) + .chain(message.references.iter()) + { + if let Some(value) = normalize_thread_message_id(value) + && known_ids.insert(value.clone()) + { + pending_ids.push_back(value); + } + } +} + +fn thread_match_basis(message: &MessageInfo, query_id: &str) -> Vec { + let mut basis = Vec::new(); + if message + .message_id + .as_deref() + .and_then(normalize_thread_message_id) + .as_deref() + == Some(query_id) + { + basis.push(format!("Message-ID equals {query_id}")); + } + if message + .in_reply_to + .as_deref() + .and_then(normalize_thread_message_id) + .as_deref() + == Some(query_id) + { + basis.push(format!("In-Reply-To equals {query_id}")); + } + if message + .references + .iter() + .filter_map(|value| normalize_thread_message_id(value)) + .any(|value| value == query_id) + { + basis.push(format!("References contains {query_id}")); + } + basis +} + +fn push_record_warning(warnings: &mut Vec, warning: String) { + const MAX_WARNINGS: usize = 20; + if warnings.len() < MAX_WARNINGS && !warnings.contains(&warning) { + warnings.push(warning); + } +} + +fn thread_selection_digest( + account: &str, + seed: &MailboxMessageIdentity, + messages: &[ThreadRecordMessage], +) -> Result { + use sha2::{Digest as _, Sha256}; + + let canonical = serde_json::to_vec(&serde_json::json!({ + "schema": "agentmail.thread-selection.v1", + "account": account, + "seed": seed, + "strategy": "exact-rfc-message-id-graph", + "messages": messages, + })) + .map_err(|error| { + AgentmailError::Other(format!("failed to serialize thread selection: {error}")) + })?; + Ok(Sha256::digest(canonical) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +fn mailbox_names_equal(left: &str, right: &str) -> bool { + left == right || (left.eq_ignore_ascii_case("INBOX") && right.eq_ignore_ascii_case("INBOX")) +} + +fn find_mailbox_layout<'a>( + layout: &'a [imap_client::MailboxLayout], + requested: &str, +) -> Option<&'a imap_client::MailboxLayout> { + layout + .iter() + .find(|entry| mailbox_names_equal(&entry.path, requested)) +} + +fn mailbox_is_same_or_descendant(candidate: &str, parent: &str, delimiter: Option<&str>) -> bool { + if mailbox_names_equal(candidate, parent) { + return true; + } + let Some(delimiter) = delimiter.filter(|delimiter| !delimiter.is_empty()) else { + return false; + }; + candidate + .strip_prefix(parent) + .is_some_and(|suffix| suffix.starts_with(delimiter)) +} + +async fn mailbox_mutation_preflight( + session: &mut imap_client::ImapSession, + mailbox: &imap_client::MailboxLayout, + layout: &[imap_client::MailboxLayout], + kind: MailboxMutationKind, +) -> Result { + let message_count = if mailbox.is_selectable() { + imap_client::mailbox_status(session, &mailbox.path, false) + .await? + .exists + } else { + 0 + }; + let mut descendants: Vec = layout + .iter() + .filter(|entry| { + entry.path != mailbox.path + && mailbox_is_same_or_descendant( + &entry.path, + &mailbox.path, + mailbox.delimiter.as_deref(), + ) + }) + .map(|entry| entry.path.clone()) + .collect(); + descendants.sort(); + let mut confirmations_required = vec![ + match kind { + MailboxMutationKind::Rename => "confirmRename=true".to_string(), + MailboxMutationKind::Delete => "confirmDelete=true".to_string(), + }, + format!("expectedMessageCount={message_count}"), + ]; + if matches!(kind, MailboxMutationKind::Delete) && message_count > 0 { + confirmations_required.push("confirmNonEmpty=true".to_string()); + } + if !mailbox.roles.is_empty() { + confirmations_required.push("confirmSpecialUse=true".to_string()); + } + if !descendants.is_empty() { + confirmations_required.push("confirmDescendants=true".to_string()); + } + Ok(MailboxMutationPreflight { + message_count, + roles: mailbox.roles.clone(), + descendants, + confirmations_required, + }) +} + +fn require_expected_message_count(expected: Option, actual: u32) -> Result<()> { + match expected { + Some(expected) if expected == actual => Ok(()), + Some(expected) => Err(AgentmailError::Other(format!( + "mailbox message count changed: expected {expected}, current count is {actual}; preview again before confirming" + ))), + None => Err(AgentmailError::Other(format!( + "expectedMessageCount is required for mutation; preview reported {actual}" + ))), + } +} + +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) +} + +fn push_reply_recipient( + output: &mut Vec, + seen: &mut hashbrown::HashSet, + own: &hashbrown::HashSet, + recipient: &str, +) { + let recipient = recipient.trim(); + if recipient.is_empty() { + return; + } + let identity = + canonical_recipient_address(recipient).unwrap_or_else(|| recipient.to_ascii_lowercase()); + if own.contains(&identity) || !seen.insert(identity) { + return; + } + output.push(recipient.to_string()); +} + +fn reply_subject(subject: &str) -> String { + let subject = subject.trim(); + if subject + .get(..3) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("re:")) + { + subject.to_string() + } else if subject.is_empty() { + "Re:".to_string() + } else { + format!("Re: {subject}") + } +} + +fn validate_draft_payload( + body: &str, + to: &[String], + cc: &[String], + bcc: &[String], + reply_to: &[String], + attachments: &[DraftAttachment], +) -> Result<()> { + if to.is_empty() && cc.is_empty() && bcc.is_empty() { + return Err(AgentmailError::Other( + "At least one recipient (to, cc, or bcc) is required".to_string(), + )); + } + let recipient_count = to + .len() + .checked_add(cc.len()) + .and_then(|count| count.checked_add(bcc.len())) + .and_then(|count| count.checked_add(reply_to.len())) + .ok_or_else(|| AgentmailError::Other("draft recipient count overflow".to_string()))?; + if recipient_count > MAX_DRAFT_RECIPIENTS { + return Err(AgentmailError::Other(format!( + "draft has {recipient_count} recipients; maximum is {MAX_DRAFT_RECIPIENTS}" + ))); + } + if body.len() > MAX_DRAFT_BODY_BYTES { + return Err(AgentmailError::Other(format!( + "draft body is {} bytes; maximum is {MAX_DRAFT_BODY_BYTES}", + body.len() + ))); + } + if attachments.len() > MAX_DRAFT_ATTACHMENTS { + return Err(AgentmailError::Other(format!( + "draft has {} attachments; maximum is {MAX_DRAFT_ATTACHMENTS}", + attachments.len() + ))); + } + let mut total = 0usize; + for attachment in attachments { + if attachment.data.len() > MAX_DRAFT_ATTACHMENT_BYTES { + return Err(AgentmailError::Other(format!( + "draft attachment '{}' is {} bytes; per-file maximum is {MAX_DRAFT_ATTACHMENT_BYTES}", + attachment.filename, + attachment.data.len() + ))); + } + total = total + .checked_add(attachment.data.len()) + .ok_or_else(|| AgentmailError::Other("draft attachment size overflow".to_string()))?; + } + if total > MAX_DRAFT_ATTACHMENTS_TOTAL_BYTES { + return Err(AgentmailError::Other(format!( + "draft attachments total {total} bytes; maximum is {MAX_DRAFT_ATTACHMENTS_TOTAL_BYTES}" + ))); + } + Ok(()) +} + /// What a delete sweep matches. The discovery predicate is the only thing that /// differs across the list-id, exact-sender, and unsubscribe-cleanup deletes; /// everything else (windowed draining, chunked expunge, per-mailbox tallying, @@ -4980,6 +6098,39 @@ pub fn bits_to_color(flags: &[String]) -> Option<&'static str> { mod tests { use super::*; + #[test] + fn only_inbox_is_case_insensitive_for_mailbox_mutations() { + assert!(mailbox_names_equal("INBOX", "inbox")); + assert!(!mailbox_names_equal("Archive", "archive")); + } + + #[test] + fn reply_subject_adds_exactly_one_prefix() { + assert_eq!(reply_subject("Status"), "Re: Status"); + assert_eq!(reply_subject("re: Status"), "re: Status"); + assert_eq!(reply_subject(" RE: Status "), "RE: Status"); + } + + #[test] + fn exact_thread_matching_normalizes_brackets_but_not_substrings() { + let raw = b"From: sender@example.com\r\nSubject: update\r\nMessage-ID: \r\nIn-Reply-To: \r\nReferences: \r\n\r\n"; + let message = parser::parse_rfc822(raw, 1, Vec::new(), None, "INBOX", "work", false, false) + .expect("parse headers"); + assert_eq!( + thread_match_basis(&message, ""), + vec![ + "In-Reply-To equals ".to_string(), + "References contains ".to_string(), + ] + ); + assert!(thread_match_basis(&message, "").is_empty()); + assert_eq!( + normalize_thread_message_id("parent@example.com").as_deref(), + Some("") + ); + assert!(normalize_thread_message_id("bad id@example.com").is_none()); + } + #[test] fn archive_filename_requires_one_portable_component() { assert!(validate_plain_filename("42.eml").is_ok()); diff --git a/src/mcp/args.rs b/src/mcp/args.rs index fcc4aa3..8886310 100644 --- a/src/mcp/args.rs +++ b/src/mcp/args.rs @@ -239,6 +239,15 @@ pub(super) struct CreateDraftArgs { #[schemars(description = "Bcc recipient email addresses.")] pub(super) bcc: Vec, #[serde(default)] + #[schemars(description = "Reply-To header addresses for responses to this draft.")] + pub(super) reply_to: Vec, + #[serde(default)] + #[schemars(description = "Message-ID this draft replies to. Angle brackets are optional.")] + pub(super) in_reply_to: Option, + #[serde(default)] + #[schemars(description = "Ordered ancestor Message-IDs for the References header.")] + pub(super) references: Vec, + #[serde(default)] #[schemars( length(max = 20), description = "Attachments to include (maximum 20 files, 25 MiB each, 40 MiB aggregate). Each entry requires a local filesystem 'path'. 'filename' and 'contentType' are optional and will be inferred when omitted." @@ -264,6 +273,75 @@ pub(super) struct DraftAttachmentArg { pub(super) content_type: Option, } +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(inline)] +pub(super) enum ReplyModeArg { + Reply, + ReplyAll, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(description = "Arguments for creating a reply or reply-all draft from a live message.")] +pub(super) struct CreateReplyDraftArgs { + pub(super) account: String, + pub(super) mailbox: String, + #[schemars(range(min = 1))] + pub(super) uid: u32, + #[schemars(range(min = 1))] + pub(super) expected_uid_validity: u32, + pub(super) mode: ReplyModeArg, + #[serde(default)] + #[schemars(description = "Optional subject override. Defaults to a single Re: prefix.")] + pub(super) subject: Option, + #[serde(default)] + pub(super) body: String, + #[serde(default)] + #[schemars(description = "Explicit Bcc recipients. Bcc is never inferred from the source.")] + pub(super) bcc: Vec, + #[serde(default)] + #[schemars(description = "Reply-To header addresses for the new draft.")] + pub(super) reply_to: Vec, + #[serde(default)] + #[schemars(length(max = 20))] + pub(super) attachments: Vec, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(description = "Complete replacement specification for one live IMAP draft.")] +pub(super) struct UpdateDraftArgs { + pub(super) account: String, + pub(super) mailbox: String, + #[schemars(range(min = 1))] + pub(super) uid: u32, + #[schemars(range(min = 1))] + pub(super) expected_uid_validity: u32, + #[serde(default)] + pub(super) subject: String, + #[serde(default)] + pub(super) body: String, + #[serde(default)] + pub(super) to: Vec, + #[serde(default)] + pub(super) cc: Vec, + #[serde(default)] + pub(super) bcc: Vec, + #[serde(default)] + pub(super) reply_to: Vec, + #[serde(default)] + pub(super) in_reply_to: Option, + #[serde(default)] + pub(super) references: Vec, + #[serde(default)] + #[schemars( + length(max = 20), + description = "Complete replacement attachment list. Omitted means no attachments; partial preservation is intentionally unsupported." + )] + pub(super) attachments: Vec, +} + #[derive(Debug, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[schemars(description = "Arguments for moving a message between mailboxes.")] @@ -653,6 +731,63 @@ pub(super) struct CreateMailboxArgs { pub(super) mailbox: String, } +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(description = "Arguments for previewing or confirming a guarded mailbox rename.")] +pub(super) struct RenameMailboxArgs { + #[schemars(description = "Account name (required).")] + pub(super) account: String, + #[schemars(description = "Existing mailbox name.")] + pub(super) mailbox: String, + #[schemars(description = "New mailbox name. The destination must not already exist.")] + pub(super) new_mailbox: String, + #[serde(default = "default_false")] + #[schemars( + description = "False returns a live preflight only. Set true to perform the rename." + )] + pub(super) confirm_rename: bool, + #[schemars( + description = "Exact messageCount from the latest preflight; required when confirmRename=true." + )] + pub(super) expected_message_count: Option, + #[serde(default = "default_false")] + #[schemars(description = "Acknowledge renaming a special-use mailbox.")] + pub(super) confirm_special_use: bool, + #[serde(default = "default_false")] + #[schemars( + description = "Acknowledge that the mailbox has descendants whose paths may change." + )] + pub(super) confirm_descendants: bool, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(description = "Arguments for previewing or confirming a guarded mailbox delete.")] +pub(super) struct DeleteMailboxArgs { + #[schemars(description = "Account name (required).")] + pub(super) account: String, + #[schemars(description = "Mailbox name to delete.")] + pub(super) mailbox: String, + #[serde(default = "default_false")] + #[schemars( + description = "False returns a live preflight only. Set true to perform the delete." + )] + pub(super) confirm_delete: bool, + #[schemars( + description = "Exact messageCount from the latest preflight; required when confirmDelete=true." + )] + pub(super) expected_message_count: Option, + #[serde(default = "default_false")] + #[schemars(description = "Acknowledge deleting a non-empty mailbox.")] + pub(super) confirm_non_empty: bool, + #[serde(default = "default_false")] + #[schemars(description = "Acknowledge deleting a special-use mailbox.")] + pub(super) confirm_special_use: bool, + #[serde(default = "default_false")] + #[schemars(description = "Acknowledge that the mailbox has descendants.")] + pub(super) confirm_descendants: bool, +} + #[derive(Debug, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[schemars(description = "Arguments for downloading message attachments to disk.")] @@ -673,7 +808,7 @@ pub(super) struct DownloadAttachmentsArgs { )] pub(super) expected_uid_validity: u32, #[schemars( - description = "Directory inside AGENTMAIL_FILE_ROOT. Defaults to the sandbox root." + description = "Directory inside the active session workspace (standalone server: AGENTMAIL_FILE_ROOT). Defaults to that workspace root." )] pub(super) output_dir: Option, } @@ -698,7 +833,7 @@ pub(super) struct DownloadMessageSourceArgs { )] pub(super) expected_uid_validity: u32, #[schemars( - description = "Directory inside AGENTMAIL_FILE_ROOT. Defaults to the sandbox root." + description = "Directory inside the active session workspace (standalone server: AGENTMAIL_FILE_ROOT). Defaults to that workspace root." )] pub(super) output_dir: Option, #[schemars( @@ -733,7 +868,7 @@ pub(super) struct DownloadThreadArgs { )] pub(super) expected_uid_validity: u32, #[schemars( - description = "Directory inside AGENTMAIL_FILE_ROOT. Defaults to the sandbox root." + description = "Directory inside the active session workspace (standalone server: AGENTMAIL_FILE_ROOT). Defaults to that workspace root." )] pub(super) output_dir: Option, #[schemars( @@ -742,6 +877,52 @@ pub(super) struct DownloadThreadArgs { pub(super) manifest_filename: Option, } +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars( + description = "Seed identity for previewing an exact cross-mailbox RFC Message-ID graph." +)] +pub(super) struct PreviewThreadRecordArgs { + pub(super) account: String, + pub(super) mailbox: String, + #[schemars(range(min = 1))] + pub(super) uid: u32, + #[schemars(range(min = 1))] + pub(super) expected_uid_validity: u32, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars( + description = "Confirmed export of an exact thread preview as PDF, RFC822 sources, and an integrity manifest." +)] +pub(super) struct ExportThreadRecordArgs { + pub(super) account: String, + pub(super) mailbox: String, + #[schemars(range(min = 1))] + pub(super) uid: u32, + #[schemars(range(min = 1))] + pub(super) expected_uid_validity: u32, + #[schemars( + length(min = 64, max = 64), + description = "Exact selectionDigest from the latest preview_thread_record result. The export re-discovers the graph and refuses any drift." + )] + pub(super) selection_digest: String, + #[schemars( + length(min = 1, max = 4000), + description = "User-supplied explanation of why the record is being prepared. Printed on the PDF cover and preserved in the manifest." + )] + pub(super) purpose: String, + #[schemars( + description = "Directory inside the active session workspace. Defaults to the workspace root." + )] + pub(super) output_dir: Option, + #[schemars( + description = "Optional portable directory name for the new bundle. Existing paths are never overwritten." + )] + pub(super) bundle_name: Option, +} + #[derive(Debug, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[schemars( diff --git a/src/mcp/file_access.rs b/src/mcp/file_access.rs index f9b0714..e222c27 100644 --- a/src/mcp/file_access.rs +++ b/src/mcp/file_access.rs @@ -10,9 +10,9 @@ //! canonicalized, so a symlink pointing outside the root fails the prefix //! check). //! -//! The root is `AGENTMAIL_FILE_ROOT` when set, else `~/.agentmail/files`. An -//! operator who wants the agent to reach a wider tree sets the env explicitly — -//! deny-by-default, widen-on-purpose. +//! 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. use std::path::{Component, Path, PathBuf}; @@ -38,7 +38,6 @@ impl FileAccessPolicy { } /// Build a policy with an explicit root (tests / embedders). - #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn with_root(root: impl Into) -> Self { Self { root: root.into() } } @@ -81,8 +80,7 @@ impl FileAccessPolicy { fn escape_error(&self, requested: &str) -> String { format!( - "path '{requested}' is outside the allowed file sandbox ({}); \ - move the file into that directory or set AGENTMAIL_FILE_ROOT to widen access", + "path '{requested}' is outside the allowed workspace root ({})", self.root.display() ) } @@ -173,7 +171,7 @@ mod tests { let err = policy .confine_read(outside.to_str().unwrap()) .expect_err("out-of-root read must be rejected"); - assert!(err.contains("outside the allowed file sandbox"), "{err}"); + assert!(err.contains("outside the allowed workspace root"), "{err}"); // `..` traversal is rejected lexically. let err = policy @@ -190,7 +188,7 @@ mod tests { let err = policy .confine_read("sneaky") .expect_err("symlink escape must be rejected"); - assert!(err.contains("outside the allowed file sandbox"), "{err}"); + assert!(err.contains("outside the allowed workspace root"), "{err}"); } let _ = std::fs::remove_file(&outside); @@ -214,7 +212,7 @@ mod tests { let err = policy .confine_dir(Some(outside.to_str().unwrap())) .expect_err("out-of-root write dir must be rejected"); - assert!(err.contains("outside the allowed file sandbox"), "{err}"); + assert!(err.contains("outside the allowed workspace root"), "{err}"); // `..` is rejected. let err = policy diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 8bea114..8de1446 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -18,9 +18,9 @@ use rmcp::{ CallToolRequestParams, CallToolResult, CancelTaskParams, CancelTaskResult, CompleteRequestParams, CompleteResult, CreateTaskResult, GetTaskParams, GetTaskPayloadParams, GetTaskPayloadResult, GetTaskResult, Implementation, - ListResourceTemplatesResult, ListTasksResult, Meta, PaginatedRequestParams, - ProgressNotificationParam, ReadResourceRequestParams, ReadResourceResult, - RelatedTaskMetadata, ServerCapabilities, ServerInfo, TasksCapability, + ListResourceTemplatesResult, ListResourcesResult, ListTasksResult, Meta, + PaginatedRequestParams, ProgressNotificationParam, ReadResourceRequestParams, + ReadResourceResult, RelatedTaskMetadata, ServerCapabilities, ServerInfo, TasksCapability, }, prompt_handler, service::RequestContext, @@ -30,6 +30,7 @@ use std::{panic::AssertUnwindSafe, sync::Arc}; const PREWARM_CONCURRENCY: usize = 3; const PREWARM_ACCOUNT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); +pub const WORKSPACE_ROOT_META_KEY: &str = "io.agentmuse/workspaceRoot"; // --------------------------------------------------------------------------- // Helper functions @@ -194,6 +195,7 @@ fn to_mcp_error(e: &crate::AgentmailError) -> McpError { E::Imap(_) | E::Tls(_) | E::Io(_) + | E::ImapTimeout { .. } | E::JournalSqlite(_) | E::Parse(_) | E::NotConnected @@ -254,7 +256,7 @@ pub struct AgentMailServer { task_manager: Arc>, /// Sandbox for LLM-supplied filesystem paths (attachment reads, download /// writes). See [`file_access::FileAccessPolicy`]. - file_access: file_access::FileAccessPolicy, + file_access: Option, } impl AgentMailServer { @@ -262,10 +264,46 @@ impl AgentMailServer { Self { agentmail: Arc::new(agentmail), task_manager: Arc::new(parking_lot::Mutex::new(TaskManager::new())), - file_access: file_access::FileAccessPolicy::from_env(), + file_access: Some(file_access::FileAccessPolicy::from_env()), } } + 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, + } + } + + fn file_access_for_request( + &self, + meta: &Meta, + ) -> Result { + if let Some(policy) = &self.file_access { + return Ok(policy.clone()); + } + let root = meta + .get(WORKSPACE_ROOT_META_KEY) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|root| !root.is_empty()) + .ok_or_else(|| { + McpError::invalid_params( + "this embedded AgentMail file operation requires an active session workspace", + None, + ) + })?; + let root = std::path::Path::new(root); + if !root.is_absolute() { + return Err(McpError::invalid_params( + "the active session workspace must be an absolute path", + None, + )); + } + Ok(file_access::FileAccessPolicy::with_root(root)) + } + /// Combined tool router — referenced by `#[tool_handler]`'s default /// `Self::tool_router()` expression and by the regression tests. fn tool_router() -> ToolRouter { @@ -300,12 +338,14 @@ impl ServerHandler for AgentMailServer { concat!(env!("CARGO_PKG_VERSION"), " (", env!("AGENTMAIL_BUILD_SHA"), ")"), )) .with_instructions( - "AgentMail is a full-featured IMAP email client. \ + "AgentMail is an IMAP read, organize, draft, and record server. It never sends mail. \ Start with list_accounts to discover configured accounts. \ list_mailboxes requires one account and returns selectable mailboxes only, paginated with a default of 100. \ get_messages and search_messages return metadata only, newest-first, with the mailbox UIDVALIDITY and a UIDVALIDITY-safe resourceUri for each row. \ Every row's resourceUri also rides the result as a resource_link, next to a link to that message's /info; /info carries the sibling headers and source URIs plus the attachment inventory, so follow links rather than building URIs by hand. \ - Manage email: delete_messages, delete_by_sender, delete_by_domain, delete_list_id, move_message, move_list_id, move_by_sender, move_by_domain, move_subscription, create_draft (supports attachments), create_mailbox, unsubscribe_message. \ + Manage email: delete_messages, delete_by_sender, delete_by_domain, delete_list_id, move_message, move_list_id, move_by_sender, move_by_domain, move_subscription, create_mailbox, rename_mailbox, delete_mailbox, and unsubscribe_message. Compose without sending via create_draft, create_reply_draft, and update_draft; drafts support Reply-To, Bcc, attachments, and RFC threading headers. \ + Rename and delete mailboxes are guarded preview-then-confirm operations. INBOX and mailboxes referenced by pending MOVE recovery are never eligible; non-empty, special-use, and descendant-bearing mailboxes require separate acknowledgements. \ + update_draft requires RFC 8508 REPLACE and refuses an APPEND+DELETE fallback, because a transport failure at that boundary can leave duplicate drafts. \ Bulk filing: move_list_id, move_by_sender, move_by_domain, and move_subscription move every exact match to an existing destination mailbox in one call — never loop move_message per UID for that. \ top_senders, top_domains, top_subscriptions, top_mailing_lists, list_flags, and find_attachments accept an optional mailbox — omit it to scan the entire account. \ Ranked tools use live offset pages with a maximum of 100; top_domains defaults to 20 rows and the other ranked tools default to 10. Pages may shift when mail changes. \ @@ -324,8 +364,10 @@ impl ServerHandler for AgentMailServer { unsubscribe_message requires explicit confirmOneClick=true. Optional matching-message cleanup is the nested cleanup {when, identity, deletion} object (omit it to only unsubscribe); it prefers the DKIM-authenticated List-Id, otherwise requires exact sender email + List-Unsubscribe-Post + the sample's List-Id when present, stops after a failed POST unless when=\"always\", and never silently escalates a Trash failure to permanent deletion unless deletion=\"trashThenPermanent\". \ list_flags resolves Apple Mail $MailFlagBit color flags to named colors (red, orange, yellow, green, blue, purple, gray). \ find_attachments returns mailbox-safe {mailbox, uidValidity, uid, date, resourceUri} hits; pass that identity to download_attachments. \ - Use download_message_source when exact RFC822 bytes must be saved to disk without crossing model context; use download_thread for a caller-selected UID set plus a JSON integrity manifest. Both validate UIDVALIDITY, use BODY.PEEK[], refuse overwrite, return SHA-256 and local DKIM results, and confine writes to AGENTMAIL_FILE_ROOT. \ - Message resources are email://{account}/{mailbox}/{uidValidity}/{uid} (markdown), plus /headers (exact headers), /source (bounded raw RFC822), /info (JSON metadata: subject, sender, date, flags, size, attachment inventory), and /attachments/{index} (one attachment as a blob with its own content type, 4 MiB limit). Percent-encode account and mailbox, including '/' in mailbox names as %2F. \ + Use download_message_source when exact RFC822 bytes must be saved to disk without crossing model context; use download_thread for a caller-selected UID set plus a JSON integrity manifest. Both validate UIDVALIDITY, use BODY.PEEK[], refuse overwrite, return SHA-256 and local DKIM results, and confine writes to the active session workspace (standalone server: AGENTMAIL_FILE_ROOT). \ + For a self-contained thread record, call preview_thread_record on one live message, review the exact Message-ID/In-Reply-To/References graph and selectionDigest, then pass that digest and a purpose explanation to export_thread_record. Export re-discovers and refuses drift, writes private PDF/EML/manifest artifacts, and reports recorded/submittable only after reopening and integrity checks; those flags describe packet completeness, not legal admissibility. \ + resources/list exposes one annotated email://{account} catalog root per account. Read an account root for annotated mailbox URIs, then read email://{account}/{mailbox}{?offset,limit} for paged message metadata and canonical message URIs. \ + Message resources are email://{account}/{mailbox}/{uidValidity}/{uid} (markdown), plus /headers (exact headers), /source (bounded raw RFC822), /info (JSON metadata: subject, sender, date, flags, size, attachment inventory), and /attachments/{index} (one attachment as a blob with its own content type, 4 MiB limit). Resources carry audience and priority annotations; percent-encode account and mailbox, including '/' in mailbox names as %2F. \ An attachment's canonical filename in /info is {uid}_{index}_{name} — the same name download_attachments writes to disk. \ All reads use BODY.PEEK to avoid marking messages as read.", ) @@ -341,6 +383,16 @@ impl ServerHandler for AgentMailServer { )) } + async fn list_resources( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListResourcesResult::with_all_items( + resources::account_resources(self.agentmail.account_names()), + )) + } + async fn read_resource( &self, request: ReadResourceRequestParams, @@ -532,7 +584,7 @@ where build = env!("AGENTMAIL_BUILD_SHA"), "agentmail MCP server starting" ); - let server = AgentMailServer::new(mk); + let server = AgentMailServer::new_embedded(mk); let service = server.serve(transport).await.inspect_err(|e| { eprintln!("agentmail: server error: {}", e); })?; @@ -734,7 +786,7 @@ mod tests { let tools = AgentMailServer::tool_router().list_all(); assert_eq!( tools.len(), - 31, + 37, "tool count drifted — update docs and tests" ); for tool in &tools { diff --git a/src/mcp/resources.rs b/src/mcp/resources.rs index 31bb5dc..be22fa4 100644 --- a/src/mcp/resources.rs +++ b/src/mcp/resources.rs @@ -1,7 +1,9 @@ -//! MCP resources: addressable single-message reads via `email://` URIs. +//! MCP resources: navigable account/mailbox catalogs and addressable messages. //! -//! Five URI templates are exposed. Every URI carries the complete IMAP -//! message identity so a delayed read cannot silently use a recycled UID: +//! Six URI templates are exposed. The mailbox template is a paged discovery +//! view; every message URI carries the complete IMAP identity so a delayed +//! read cannot silently use a recycled UID: +//! - `email://{account}/{mailbox}{?offset,limit}` — newest message metadata //! - `email://{account}/{mailbox}/{uidValidity}/{uid}` — markdown body //! - `email://{account}/{mailbox}/{uidValidity}/{uid}/headers` — exact headers //! - `email://{account}/{mailbox}/{uidValidity}/{uid}/source` — raw RFC822 @@ -23,10 +25,12 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode}; use rmcp::ErrorData as McpError; use rmcp::model::{ - CompleteRequestParams, CompleteResult, CompletionContext, CompletionInfo, ReadResourceResult, - ResourceContents, ResourceTemplate, + Annotations, CompleteRequestParams, CompleteResult, CompletionContext, CompletionInfo, + ReadResourceResult, Resource, ResourceContents, ResourceTemplate, Role, }; +pub(super) const EMAIL_MAILBOX_TEMPLATE: &str = "email://{account}/{mailbox}{?offset,limit}"; + pub(super) const EMAIL_BODY_TEMPLATE: &str = "email://{account}/{mailbox}/{uidValidity}/{uid}"; pub(super) const EMAIL_HEADERS_TEMPLATE: &str = "email://{account}/{mailbox}/{uidValidity}/{uid}/headers"; @@ -36,6 +40,9 @@ pub(super) const EMAIL_INFO_TEMPLATE: &str = "email://{account}/{mailbox}/{uidVa pub(super) const EMAIL_ATTACHMENT_TEMPLATE: &str = "email://{account}/{mailbox}/{uidValidity}/{uid}/attachments/{index}"; +const DEFAULT_MAILBOX_PAGE_LIMIT: usize = 25; +const MAX_MAILBOX_PAGE_LIMIT: usize = 50; + // Identity of the two representations tool results LINK to (`wire.rs` // `message_resource_links`). Shared with `email_resource_templates` below so a // link and the template it instantiates cannot disagree about what a URI is — @@ -79,6 +86,126 @@ pub(super) fn encode_segment(s: &str) -> String { utf8_percent_encode(s, SEGMENT).to_string() } +pub(super) fn assistant_annotations(priority: f32) -> Annotations { + Annotations::default() + .with_audience(vec![Role::Assistant]) + .with_priority(priority) +} + +pub(super) fn user_and_assistant_annotations(priority: f32) -> Annotations { + Annotations::default() + .with_audience(vec![Role::User, Role::Assistant]) + .with_priority(priority) +} + +pub(super) fn account_resource_uri(account: &str) -> String { + format!("email://{}", encode_segment(account)) +} + +fn mailbox_resource_uri(account: &str, mailbox: &str) -> String { + format!( + "email://{}/{}", + encode_segment(account), + encode_segment(mailbox) + ) +} + +pub(super) fn account_resources(accounts: impl IntoIterator) -> Vec { + accounts + .into_iter() + .map(|account| { + Resource::new(account_resource_uri(&account), format!("{account} mail")) + .with_title(format!("AgentMail account: {account}")) + .with_description( + "Selectable mailbox catalog. Read this resource to discover mailbox resource URIs.", + ) + .with_mime_type("application/json") + .with_annotations(assistant_annotations(0.8)) + }) + .collect() +} + +#[derive(Debug, PartialEq, Eq)] +struct MailboxIndexUri { + account: String, + mailbox: String, + offset: usize, + limit: usize, +} + +#[derive(Debug, PartialEq, Eq)] +enum CatalogResourceUri { + Account(String), + Mailbox(MailboxIndexUri), +} + +fn parse_catalog_uri(uri: &str) -> Result, String> { + let Some(rest) = uri.strip_prefix("email://") else { + return Ok(None); + }; + let (path, query) = rest + .split_once('?') + .map_or((rest, None), |(path, query)| (path, Some(query))); + let segments: Vec<&str> = path.split('/').collect(); + match segments.as_slice() { + [account] if query.is_none() => { + let account = decode_segment(account) + .ok_or_else(|| format!("invalid account segment in {uri}"))?; + Ok(Some(CatalogResourceUri::Account(account))) + } + [account, mailbox] => { + let account = decode_segment(account) + .ok_or_else(|| format!("invalid account segment in {uri}"))?; + let mailbox = decode_segment(mailbox) + .ok_or_else(|| format!("invalid mailbox segment in {uri}"))?; + let mut offset = 0usize; + let mut limit = DEFAULT_MAILBOX_PAGE_LIMIT; + let mut saw_offset = false; + let mut saw_limit = false; + if let Some(query) = query.filter(|query| !query.is_empty()) { + for pair in query.split('&') { + let (key, value) = pair + .split_once('=') + .ok_or_else(|| format!("invalid mailbox resource query in {uri}"))?; + match key { + "offset" if !saw_offset => { + offset = value.parse().map_err(|_| { + format!("offset must be an unsigned integer in {uri}") + })?; + saw_offset = true; + } + "limit" if !saw_limit => { + limit = value.parse().map_err(|_| { + format!("limit must be an unsigned integer in {uri}") + })?; + saw_limit = true; + } + "offset" | "limit" => { + return Err(format!("duplicate query parameter in {uri}")); + } + _ => return Err(format!("unsupported query parameter '{key}' in {uri}")), + } + } + } + if offset > 1_000_000 { + return Err(format!("offset exceeds 1000000 in {uri}")); + } + if !(1..=MAX_MAILBOX_PAGE_LIMIT).contains(&limit) { + return Err(format!( + "limit must be between 1 and {MAX_MAILBOX_PAGE_LIMIT} in {uri}" + )); + } + Ok(Some(CatalogResourceUri::Mailbox(MailboxIndexUri { + account, + mailbox, + offset, + limit, + }))) + } + _ => Ok(None), + } +} + fn decode_segment(s: &str) -> Option { if s.is_empty() { return None; @@ -197,6 +324,13 @@ pub(super) fn parse_email_uri(uri: &str) -> Result { pub(super) fn email_resource_templates() -> Vec { vec![ + ResourceTemplate::new(EMAIL_MAILBOX_TEMPLATE, "email-mailbox") + .with_title("Email mailbox (newest messages)") + .with_description( + "A selectable mailbox index ordered newest-first. Read an account root first to discover exact mailbox URIs. Optional offset and limit paginate metadata; limit defaults to 25 and is capped at 50.", + ) + .with_mime_type("application/json") + .with_annotations(assistant_annotations(0.8)), ResourceTemplate::new(EMAIL_BODY_TEMPLATE, EMAIL_BODY_NAME) .with_title(EMAIL_BODY_TITLE) .with_description( @@ -206,14 +340,16 @@ pub(super) fn email_resource_templates() -> Vec { and the UIDVALIDITY + UID identity from a current discovery result. \ Markdown output is limited to 100,000 characters.", ) - .with_mime_type(EMAIL_BODY_MIME), + .with_mime_type(EMAIL_BODY_MIME) + .with_annotations(assistant_annotations(0.8)), ResourceTemplate::new(EMAIL_HEADERS_TEMPLATE, "email-message-headers") .with_title("Email message headers (exact RFC822 syntax)") .with_description( "The exact RFC822 header block for a live message identity, preserving \ field names, order, folding, and line endings. Output is limited to 64 KiB.", ) - .with_mime_type("text/rfc822-headers"), + .with_mime_type("text/rfc822-headers") + .with_annotations(assistant_annotations(0.5)), ResourceTemplate::new(EMAIL_SOURCE_TEMPLATE, "email-message-source") .with_title("Email message (raw RFC822 source)") .with_description( @@ -221,7 +357,8 @@ pub(super) fn email_resource_templates() -> Vec { and MIME structure. Output is limited to 256 KiB; use the markdown, \ headers, or attachment APIs for larger messages.", ) - .with_mime_type("message/rfc822"), + .with_mime_type("message/rfc822") + .with_annotations(assistant_annotations(0.2)), ResourceTemplate::new(EMAIL_INFO_TEMPLATE, EMAIL_INFO_NAME) .with_title(EMAIL_INFO_TITLE) .with_description( @@ -232,7 +369,8 @@ pub(super) fn email_resource_templates() -> Vec { /attachments/{index} resource URI — plus sibling body, headers, \ and source resource URIs. Read this before fetching attachments.", ) - .with_mime_type(EMAIL_INFO_MIME), + .with_mime_type(EMAIL_INFO_MIME) + .with_annotations(assistant_annotations(0.5)), ResourceTemplate::new(EMAIL_ATTACHMENT_TEMPLATE, "email-message-attachment") .with_title("Email attachment (binary)") .with_description( @@ -241,10 +379,19 @@ pub(super) fn email_resource_templates() -> Vec { content type. Discover indices, names, and sizes via the /info \ resource. Attachments above 4 MiB are refused — use the \ download_attachments tool to save large files to disk.", - ), + ) + .with_annotations(user_and_assistant_annotations(0.8)), ] } +fn json_contents(uri: &str, value: &serde_json::Value) -> Result { + let text = serde_json::to_string_pretty(value) + .map_err(|error| McpError::internal_error(error.to_string(), None))?; + Ok(ReadResourceResult::new(vec![ + ResourceContents::text(text, uri).with_mime_type("application/json"), + ])) +} + /// Render a message as a markdown document: subject heading, metadata list, /// then the already markdown-normalized body. fn render_message_markdown(msg: &crate::MessageInfo) -> String { @@ -449,6 +596,89 @@ impl AgentMailServer { &self, uri: &str, ) -> Result { + if let Some(catalog) = + parse_catalog_uri(uri).map_err(|error| McpError::invalid_params(error, None))? + { + return match catalog { + CatalogResourceUri::Account(account) => { + let entries = self + .agentmail + .cached_mailbox_layout(&account) + .await + .map_err(|error| to_mcp_error(&error))?; + let mailboxes: Vec<_> = entries + .iter() + .filter(|entry| entry.is_selectable()) + .map(|entry| { + serde_json::json!({ + "name": entry.path, + "delimiter": entry.delimiter, + "roles": entry.roles, + "noInferiors": entry.no_inferiors, + "resourceUri": mailbox_resource_uri(&account, &entry.path), + }) + }) + .collect(); + json_contents( + uri, + &serde_json::json!({ + "account": account, + "mailboxCount": mailboxes.len(), + "mailboxes": mailboxes, + }), + ) + } + CatalogResourceUri::Mailbox(parsed) => { + let response = self + .agentmail + .get_messages( + &parsed.mailbox, + &parsed.account, + parsed.offset, + parsed.limit, + false, + false, + ) + .await + .map_err(|error| to_mcp_error(&error))?; + let next_offset = (response.offset + response.messages.len() < response.total) + .then_some(response.offset + response.messages.len()); + let messages: Vec<_> = response + .messages + .into_iter() + .map(|message| { + serde_json::json!({ + "uid": message.uid, + "subject": message.subject, + "sender": message.sender, + "date": message.date.map(|date| date.to_rfc3339()), + "flags": message.flags, + "size": message.size, + "resourceUri": format_email_uri( + &parsed.account, + &parsed.mailbox, + response.uid_validity, + message.uid, + ), + }) + }) + .collect(); + json_contents( + uri, + &strip_nulls(serde_json::json!({ + "account": parsed.account, + "mailbox": parsed.mailbox, + "uidValidity": response.uid_validity, + "offset": response.offset, + "limit": response.limit, + "total": response.total, + "nextOffset": next_offset, + "messages": messages, + })), + ) + } + }; + } let parsed = parse_email_uri(uri).map_err(|e| McpError::invalid_params(e, None))?; match parsed.kind { @@ -1120,6 +1350,7 @@ mod tests { assert_eq!( uris, [ + EMAIL_MAILBOX_TEMPLATE, EMAIL_BODY_TEMPLATE, EMAIL_HEADERS_TEMPLATE, EMAIL_SOURCE_TEMPLATE, @@ -1127,6 +1358,13 @@ mod tests { EMAIL_ATTACHMENT_TEMPLATE, ] ); + assert_eq!( + templates[0] + .annotations + .as_ref() + .and_then(|annotations| annotations.priority), + Some(0.8) + ); } #[test] diff --git a/src/mcp/tasks.rs b/src/mcp/tasks.rs index 149e73b..8441268 100644 --- a/src/mcp/tasks.rs +++ b/src/mcp/tasks.rs @@ -31,11 +31,14 @@ type Clock = Arc DateTime + Send + Sync>; /// 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. diff --git a/src/mcp/tools_read.rs b/src/mcp/tools_read.rs index 4b2b56f..35cd351 100644 --- a/src/mcp/tools_read.rs +++ b/src/mcp/tools_read.rs @@ -423,4 +423,45 @@ impl AgentMailServer { Err(e) => Ok(tool_error_result(&e)), } } + + #[tool( + name = "preview_thread_record", + output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid preview_thread_record output schema"), + 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", + read_only_hint = true, + open_world_hint = false + ), + execution(task_support = "optional") + )] + async fn preview_thread_record_tool( + &self, + meta: Meta, + client: Peer, + ct: CancellationToken, + Parameters(args): Parameters, + ) -> Result { + if args.mailbox.trim().is_empty() { + return Err(McpError::invalid_params("mailbox is required", None)); + } + let progress = make_progress_fn(&meta, &client); + let cancel = make_cancel_fn(ct); + let result = self + .agentmail + .preview_thread_record( + args.mailbox.trim(), + &args.account, + args.uid, + args.expected_uid_validity, + progress.callback(), + Some(&cancel), + ) + .await; + progress.finish().await; + match result { + Ok(data) => compact_result(data), + Err(error) => Ok(tool_error_result(&error)), + } + } } diff --git a/src/mcp/tools_write.rs b/src/mcp/tools_write.rs index 0fbbf5d..73a25d4 100644 --- a/src/mcp/tools_write.rs +++ b/src/mcp/tools_write.rs @@ -4,15 +4,16 @@ use super::AgentMailServer; use super::args::*; use super::wire::{ AddFlagsOutput, CreateDraftOutput, CreateMailboxOutput, DeleteByDomainOutput, - DeleteBySenderOutput, DeleteListIdOutput, DeleteMessagesOutput, DownloadAttachmentsOutput, - DownloadMessageSourceOutput, DownloadThreadOutput, MoveByDomainOutput, MoveBySenderOutput, - MoveListIdOutput, MoveMessageOutput, MoveSubscriptionOutput, ReconcileMovesOutput, - RemoveFlagsOutput, UnsubscribeMessageOutput, compact_result, tool_error_result, + DeleteBySenderOutput, DeleteListIdOutput, DeleteMailboxOutput, DeleteMessagesOutput, + DownloadAttachmentsOutput, DownloadMessageSourceOutput, DownloadThreadOutput, + MoveByDomainOutput, MoveBySenderOutput, MoveListIdOutput, MoveMessageOutput, + MoveSubscriptionOutput, ReconcileMovesOutput, RemoveFlagsOutput, RenameMailboxOutput, + UnsubscribeMessageOutput, UpdateDraftOutput, compact_result, tool_error_result, }; use super::{make_cancel_fn, make_progress_fn}; use crate::{ CleanupDeletion, CleanupIdentityMode, CleanupPolicy, CleanupWhen, DeleteMode, DraftAttachment, - UnsubscribeOptions, + ReplyMode, UnsubscribeOptions, }; use rmcp::{ ErrorData as McpError, Peer, RoleServer, @@ -27,6 +28,345 @@ const MAX_DRAFT_ATTACHMENTS: usize = 20; const MAX_DRAFT_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024; const MAX_DRAFT_ATTACHMENT_TOTAL_BYTES: u64 = 40 * 1024 * 1024; const MAX_THREAD_MESSAGES: usize = 100; +const MAX_RECORD_BUNDLE_BYTES: u64 = 512 * 1024 * 1024; +const MAX_RECORD_PDF_BODY_CHARS: usize = 500_000; + +async fn create_private_dir(path: &std::path::Path) -> crate::Result<()> { + #[cfg(unix)] + let builder = { + let mut builder = tokio::fs::DirBuilder::new(); + builder.mode(0o700); + builder + }; + #[cfg(not(unix))] + let builder = tokio::fs::DirBuilder::new(); + builder.create(path).await.map_err(|error| { + crate::AgentmailError::Other(format!( + "failed to create thread-record directory '{}': {error}", + path.display() + )) + })?; + Ok(()) +} + +fn checked_record_bytes(total: u64, next: usize) -> crate::Result { + let total = total.checked_add(next as u64).ok_or_else(|| { + crate::AgentmailError::Other("thread-record bundle size overflow".to_string()) + })?; + if total > MAX_RECORD_BUNDLE_BYTES { + return Err(crate::AgentmailError::Other(format!( + "thread-record bundle exceeds the {MAX_RECORD_BUNDLE_BYTES}-byte limit" + ))); + } + Ok(total) +} + +async fn export_thread_record( + server: &AgentMailServer, + args: &ExportThreadRecordArgs, + output_dir: &std::path::Path, + on_progress: Option<&crate::ProgressFn>, + cancel: Option<&crate::CancelFn>, +) -> crate::Result { + crate::imap_client::check_cancel(cancel)?; + let preview = server + .agentmail + .preview_thread_record( + args.mailbox.trim(), + &args.account, + args.uid, + args.expected_uid_validity, + None, + cancel, + ) + .await?; + if preview.truncated { + return Err(crate::AgentmailError::Other( + "thread preview is truncated; refusing to label an incomplete selection as recorded" + .to_string(), + )); + } + if preview.selection_digest != args.selection_digest { + return Err(crate::AgentmailError::Other(format!( + "thread selection changed after preview (expected {}, now {}); review a fresh preview before exporting", + args.selection_digest, preview.selection_digest + ))); + } + + let purpose = args.purpose.trim(); + if purpose.is_empty() || purpose.chars().count() > 4_000 { + return Err(crate::AgentmailError::Other( + "purpose must contain 1..=4000 characters".to_string(), + )); + } + let bundle_name = args.bundle_name.clone().unwrap_or_else(|| { + format!( + "agentmail-thread-record-{}", + chrono::Utc::now().format("%Y%m%dT%H%M%SZ") + ) + }); + crate::validate_plain_filename(&bundle_name)?; + let final_path = output_dir.join(&bundle_name); + if tokio::fs::try_exists(&final_path).await.map_err(|error| { + crate::AgentmailError::Other(format!( + "failed to inspect thread-record destination '{}': {error}", + final_path.display() + )) + })? { + return Err(crate::AgentmailError::Other(format!( + "refusing to overwrite existing thread-record destination '{}'", + final_path.display() + ))); + } + let temporary_path = + output_dir.join(format!(".{bundle_name}.partial-{}", uuid::Uuid::new_v4())); + create_private_dir(&temporary_path).await?; + + let result = async { + let sources_path = temporary_path.join("sources"); + create_private_dir(&sources_path).await?; + let mut files = Vec::with_capacity(preview.messages.len()); + let mut presentations = Vec::with_capacity(preview.messages.len()); + let mut total_bytes = 0_u64; + let presentation_char_limit = + MAX_RECORD_PDF_BODY_CHARS / preview.messages.len().max(1); + + for (index, selected) in preview.messages.iter().enumerate() { + crate::imap_client::check_cancel(cancel)?; + let filename = format!( + "message-{:03}-{}-{}.eml", + index + 1, + selected.identity.uid_validity, + selected.identity.uid + ); + let downloaded = server + .agentmail + .download_message_source( + &selected.identity.mailbox, + &args.account, + selected.identity.uid, + selected.identity.uid_validity, + &sources_path, + &filename, + cancel, + ) + .await?; + total_bytes = checked_record_bytes(total_bytes, downloaded.bytes)?; + let raw = tokio::fs::read(&downloaded.path).await.map_err(|error| { + crate::AgentmailError::Other(format!( + "saved RFC822 source could not be reopened '{}': {error}", + downloaded.path + )) + })?; + let reopened_hash = crate::record::sha256_bytes(&raw); + if raw.len() != downloaded.bytes || reopened_hash != downloaded.sha256 { + return Err(crate::AgentmailError::Other(format!( + "saved RFC822 source failed its reopen/hash check: {filename}" + ))); + } + let uid = selected.identity.uid; + let presentation = tokio::task::spawn_blocking(move || { + crate::record::analyze_message(&raw, uid, presentation_char_limit) + }) + .await + .map_err(|error| { + crate::AgentmailError::Other(format!( + "RFC822 presentation analysis task failed: {error}" + )) + })??; + presentations.push(presentation); + files.push(crate::ThreadRecordFile { + identity: selected.identity.clone(), + filename: format!("sources/{filename}"), + bytes: downloaded.bytes, + sha256: downloaded.sha256, + message_id: downloaded.message_id, + date: downloaded.date, + from_header: downloaded.from_header, + subject: downloaded.subject, + dkim: downloaded.dkim, + }); + if let Some(progress) = on_progress { + progress((index + 1) as u64, preview.messages.len() as u64); + } + } + + let mut limitations = vec![ + "Thread membership uses exact RFC Message-ID, In-Reply-To, and References values; it does not infer relationships from similar subjects." + .to_string(), + "The PDF is a readable presentation. The complete, integrity-bearing content is preserved in the hashed RFC822 (.eml) sources." + .to_string(), + "DKIM was checked against DNS at export time. SPF cannot be independently recomputed from archived message bytes because the SMTP connection and envelope inputs are absent." + .to_string(), + "Recorded and submittable describe this bundle's structure and verification, not legal admissibility, authenticity findings, or acceptance by any recipient." + .to_string(), + ]; + for warning in &preview.warnings { + limitations.push(format!("Discovery warning: {warning}")); + } + if presentations.iter().any(|message| message.body_truncated) { + limitations.push( + "At least one body was shortened in the PDF presentation; its complete bytes remain in the corresponding hashed .eml source." + .to_string(), + ); + } + let generated_at = chrono::Utc::now().to_rfc3339(); + let pdf_preview = preview.clone(); + let pdf_files = files.clone(); + let pdf_presentations = presentations.clone(); + let pdf_limitations = limitations.clone(); + let pdf_purpose = purpose.to_string(); + let pdf_generated_at = generated_at.clone(); + let pdf = tokio::task::spawn_blocking(move || { + crate::record::render_thread_record_pdf( + &pdf_purpose, + &pdf_generated_at, + &pdf_preview, + &pdf_files, + &pdf_presentations, + &pdf_limitations, + ) + }) + .await + .map_err(|error| { + crate::AgentmailError::Other(format!("PDF render task failed: {error}")) + })??; + total_bytes = checked_record_bytes(total_bytes, pdf.len())?; + let pdf_filename = "thread-record.pdf"; + let pdf_temporary_path = temporary_path.join(pdf_filename); + crate::write_new_private_file(&pdf_temporary_path, &pdf).await?; + let reopened_pdf = tokio::fs::read(&pdf_temporary_path).await.map_err(|error| { + crate::AgentmailError::Other(format!( + "generated PDF could not be reopened '{}': {error}", + pdf_temporary_path.display() + )) + })?; + let pdf_sha256 = crate::record::sha256_bytes(&reopened_pdf); + let expected_pdf_sha256 = crate::record::sha256_bytes(&pdf); + if reopened_pdf.len() != pdf.len() || pdf_sha256 != expected_pdf_sha256 { + return Err(crate::AgentmailError::Other( + "generated PDF failed its reopen/hash check".to_string(), + )); + } + let pdf_pages = tokio::task::spawn_blocking(move || { + crate::record::verify_pdf(&reopened_pdf) + }) + .await + .map_err(|error| { + crate::AgentmailError::Other(format!("PDF verification task failed: {error}")) + })??; + + let submission_explanation = "Structurally ready to submit as a record because it contains a purpose statement, deterministic selection rationale, exact RFC822 sources, live storage identities, SHA-256 hashes, DKIM results, a readable chronology, an attachment inventory, and explicit limitations. The receiving authority still determines acceptance and legal admissibility.".to_string(); + let manifest = serde_json::json!({ + "schemaVersion": "agentmail.thread-record.v1", + "recorded": true, + "submittable": true, + "submissionExplanation": &submission_explanation, + "generatedAt": &generated_at, + "purpose": purpose, + "account": args.account, + "selection": &preview, + "artifacts": { + "presentationPdf": { + "filename": pdf_filename, + "bytes": pdf.len(), + "sha256": pdf_sha256, + "pages": pdf_pages, + }, + "sources": &files, + }, + "limitations": &limitations, + }); + let mut manifest_bytes = serde_json::to_vec_pretty(&manifest).map_err(|error| { + crate::AgentmailError::Other(format!( + "failed to serialize thread-record manifest: {error}" + )) + })?; + manifest_bytes.push(b'\n'); + total_bytes = checked_record_bytes(total_bytes, manifest_bytes.len())?; + let manifest_filename = "manifest.json"; + let manifest_temporary_path = temporary_path.join(manifest_filename); + crate::write_new_private_file(&manifest_temporary_path, &manifest_bytes).await?; + let reopened_manifest = tokio::fs::read(&manifest_temporary_path) + .await + .map_err(|error| { + crate::AgentmailError::Other(format!( + "thread-record manifest could not be reopened: {error}" + )) + })?; + if crate::record::sha256_bytes(&reopened_manifest) + != crate::record::sha256_bytes(&manifest_bytes) + { + return Err(crate::AgentmailError::Other( + "thread-record manifest failed its reopen/hash check".to_string(), + )); + } + let parsed_manifest: serde_json::Value = serde_json::from_slice(&reopened_manifest) + .map_err(|error| { + crate::AgentmailError::Other(format!( + "thread-record manifest failed to parse after writing: {error}" + )) + })?; + let manifest_sources = parsed_manifest["artifacts"]["sources"] + .as_array() + .map_or(0, Vec::len); + if parsed_manifest["recorded"] != serde_json::Value::Bool(true) + || parsed_manifest["submittable"] != serde_json::Value::Bool(true) + || parsed_manifest["selection"]["selectionDigest"] + != serde_json::Value::String(args.selection_digest.clone()) + || manifest_sources != preview.messages.len() + { + return Err(crate::AgentmailError::Other( + "thread-record manifest failed its structural verification".to_string(), + )); + } + + if tokio::fs::try_exists(&final_path).await.map_err(|error| { + crate::AgentmailError::Other(format!( + "failed to recheck thread-record destination '{}': {error}", + final_path.display() + )) + })? { + return Err(crate::AgentmailError::Other(format!( + "thread-record destination appeared during export; refusing to overwrite '{}'", + final_path.display() + ))); + } + tokio::fs::rename(&temporary_path, &final_path) + .await + .map_err(|error| { + crate::AgentmailError::Other(format!( + "failed to publish verified thread-record bundle '{}': {error}", + final_path.display() + )) + })?; + let final_path = tokio::fs::canonicalize(&final_path).await.map_err(|error| { + crate::AgentmailError::Other(format!( + "published thread-record bundle could not be resolved: {error}" + )) + })?; + Ok(crate::ThreadRecordExportResponse { + recorded: true, + submittable: true, + submission_explanation, + account: args.account.clone(), + purpose: purpose.to_string(), + selection_digest: args.selection_digest.clone(), + message_count: preview.messages.len(), + bundle_path: final_path.display().to_string(), + pdf_path: final_path.join(pdf_filename).display().to_string(), + manifest_path: final_path.join(manifest_filename).display().to_string(), + total_bytes, + limitations, + }) + } + .await; + + if result.is_err() { + let _ = tokio::fs::remove_dir_all(&temporary_path).await; + } + result +} /// Map the flat `permanent` tool argument to a `DeleteMode`. fn delete_mode(permanent: bool) -> DeleteMode { @@ -37,6 +377,13 @@ fn delete_mode(permanent: bool) -> DeleteMode { } } +fn reply_mode(mode: ReplyModeArg) -> ReplyMode { + match mode { + ReplyModeArg::Reply => ReplyMode::Reply, + ReplyModeArg::ReplyAll => ReplyMode::ReplyAll, + } +} + /// Map the wire cleanup spec to the internal policy. fn cleanup_policy(spec: UnsubscribeCleanupSpec) -> CleanupPolicy { CleanupPolicy { @@ -84,6 +431,137 @@ async fn archive_target_exists( Ok(None) } +async fn load_draft_attachments( + file_access: &super::file_access::FileAccessPolicy, + attachments: &[DraftAttachmentArg], +) -> Result, McpError> { + if attachments.len() > MAX_DRAFT_ATTACHMENTS { + return Err(McpError::invalid_params( + format!("attachments supports at most {MAX_DRAFT_ATTACHMENTS} files"), + None, + )); + } + + let mut preflight = Vec::with_capacity(attachments.len()); + let mut preflight_total = 0_u64; + for (index, attachment) in attachments.iter().enumerate() { + let safe_path = file_access + .confine_read(&attachment.path) + .map_err(|reason| { + McpError::invalid_params(format!("attachment #{}: {reason}", index + 1), None) + })?; + let file = tokio::fs::File::open(&safe_path).await.map_err(|error| { + McpError::invalid_params( + format!( + "Failed to open attachment #{} at '{}': {error}", + index + 1, + attachment.path + ), + None, + ) + })?; + let metadata = file.metadata().await.map_err(|error| { + McpError::invalid_params( + format!( + "Failed to inspect attachment #{} at '{}': {error}", + index + 1, + attachment.path + ), + None, + ) + })?; + if !metadata.is_file() { + return Err(McpError::invalid_params( + format!("attachment #{} is not a regular file", index + 1), + None, + )); + } + let size = metadata.len(); + if size > MAX_DRAFT_ATTACHMENT_BYTES { + return Err(McpError::invalid_params( + format!( + "attachment #{} is {size} bytes; maximum per file is {MAX_DRAFT_ATTACHMENT_BYTES} bytes", + index + 1 + ), + None, + )); + } + preflight_total = preflight_total + .checked_add(size) + .ok_or_else(|| McpError::invalid_params("attachment aggregate size overflow", None))?; + if preflight_total > MAX_DRAFT_ATTACHMENT_TOTAL_BYTES { + return Err(McpError::invalid_params( + format!( + "attachments total {preflight_total} bytes; aggregate maximum is {MAX_DRAFT_ATTACHMENT_TOTAL_BYTES} bytes" + ), + None, + )); + } + preflight.push((size, file)); + } + + let mut loaded = Vec::with_capacity(attachments.len()); + let mut loaded_total = 0_u64; + for (index, (attachment, (preflight_size, file))) in + attachments.iter().zip(preflight).enumerate() + { + let mut data = Vec::with_capacity(preflight_size as usize); + file.take(MAX_DRAFT_ATTACHMENT_BYTES + 1) + .read_to_end(&mut data) + .await + .map_err(|error| { + McpError::invalid_params( + format!( + "Failed to read attachment #{} at '{}': {error}", + index + 1, + attachment.path + ), + None, + ) + })?; + if data.len() as u64 > MAX_DRAFT_ATTACHMENT_BYTES { + return Err(McpError::invalid_params( + format!( + "attachment #{} grew beyond the {MAX_DRAFT_ATTACHMENT_BYTES}-byte limit while being read", + index + 1 + ), + None, + )); + } + loaded_total = loaded_total + .checked_add(data.len() as u64) + .ok_or_else(|| McpError::invalid_params("attachment aggregate size overflow", None))?; + if loaded_total > MAX_DRAFT_ATTACHMENT_TOTAL_BYTES { + return Err(McpError::invalid_params( + format!( + "attachments grew beyond the {MAX_DRAFT_ATTACHMENT_TOTAL_BYTES}-byte aggregate limit while being read" + ), + None, + )); + } + let filename = attachment + .filename + .clone() + .or_else(|| { + std::path::Path::new(&attachment.path) + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_string) + }) + .unwrap_or_else(|| format!("attachment-{}", index + 1)); + let content_type = attachment + .content_type + .clone() + .unwrap_or_else(|| guess_content_type(&filename)); + loaded.push(DraftAttachment { + filename, + content_type, + data, + }); + } + Ok(loaded) +} + #[tool_router(router = write_tools_router, vis = "pub(super)")] impl AgentMailServer { #[tool( @@ -114,6 +592,81 @@ impl AgentMailServer { } } + #[tool( + name = "rename_mailbox", + output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid rename_mailbox output schema"), + 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", + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false + ) + )] + async fn rename_mailbox_tool( + &self, + Parameters(args): Parameters, + ) -> Result { + if args.mailbox.trim().is_empty() || args.new_mailbox.trim().is_empty() { + return Err(McpError::invalid_params( + "mailbox and newMailbox are required", + None, + )); + } + match self + .agentmail + .rename_mailbox( + &args.account, + args.mailbox.trim(), + args.new_mailbox.trim(), + args.confirm_rename, + args.expected_message_count, + args.confirm_special_use, + args.confirm_descendants, + ) + .await + { + Ok(data) => compact_result(RenameMailboxOutput::from(data)), + Err(error) => Ok(tool_error_result(&error)), + } + } + + #[tool( + name = "delete_mailbox", + output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid delete_mailbox output schema"), + 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", + read_only_hint = false, + destructive_hint = true, + idempotent_hint = true + ) + )] + async fn delete_mailbox_tool( + &self, + Parameters(args): Parameters, + ) -> Result { + if args.mailbox.trim().is_empty() { + return Err(McpError::invalid_params("mailbox is required", None)); + } + match self + .agentmail + .delete_mailbox( + &args.account, + args.mailbox.trim(), + args.confirm_delete, + args.expected_message_count, + args.confirm_non_empty, + args.confirm_special_use, + args.confirm_descendants, + ) + .await + { + Ok(data) => compact_result(DeleteMailboxOutput::from(data)), + Err(error) => Ok(tool_error_result(&error)), + } + } + #[tool( name = "delete_messages", output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid delete_messages output schema"), @@ -264,6 +817,7 @@ impl AgentMailServer { )] async fn download_attachments_tool( &self, + meta: Meta, Parameters(args): Parameters, ) -> Result { if args.mailbox.trim().is_empty() { @@ -273,7 +827,8 @@ impl AgentMailServer { // prompt-injection payload must not be able to write attacker bytes // into a sensitive directory (e.g. ~/.ssh). Absolute/`..` escapes are // rejected; the default lands in the sandbox root. - let output_dir = match self.file_access.confine_dir(args.output_dir.as_deref()) { + let file_access = self.file_access_for_request(&meta)?; + let output_dir = match file_access.confine_dir(args.output_dir.as_deref()) { Ok(dir) => dir, Err(reason) => return Err(McpError::invalid_params(reason, None)), }; @@ -300,7 +855,7 @@ impl AgentMailServer { #[tool( name = "download_message_source", output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid download_message_source output schema"), - 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 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.", + 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", read_only_hint = false, @@ -311,6 +866,7 @@ impl AgentMailServer { )] async fn download_message_source_tool( &self, + meta: Meta, ct: CancellationToken, Parameters(args): Parameters, ) -> Result { @@ -319,7 +875,7 @@ impl AgentMailServer { } let filename = archive_filename(args.uid, args.filename.as_deref())?; let output_dir = self - .file_access + .file_access_for_request(&meta)? .confine_dir(args.output_dir.as_deref()) .map_err(|reason| McpError::invalid_params(reason, None))?; let cancel = make_cancel_fn(ct); @@ -345,7 +901,7 @@ impl AgentMailServer { #[tool( name = "download_thread", output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid download_thread output schema"), - 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 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.", + 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", read_only_hint = false, @@ -385,7 +941,7 @@ impl AgentMailServer { Some(args.manifest_filename.as_deref().unwrap_or("manifest.json")), )?; let output_dir = self - .file_access + .file_access_for_request(&meta)? .confine_dir(args.output_dir.as_deref()) .map_err(|reason| McpError::invalid_params(reason, None))?; let source_filenames = args @@ -464,6 +1020,56 @@ impl AgentMailServer { compact_result(output) } + #[tool( + name = "export_thread_record", + output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid export_thread_record output schema"), + 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", + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ), + execution(task_support = "optional") + )] + async fn export_thread_record_tool( + &self, + meta: Meta, + client: Peer, + ct: CancellationToken, + Parameters(args): Parameters, + ) -> Result { + if args.mailbox.trim().is_empty() { + return Err(McpError::invalid_params("mailbox is required", None)); + } + if args.selection_digest.len() != 64 + || !args + .selection_digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(McpError::invalid_params( + "selectionDigest must be a 64-character lowercase SHA-256 value from preview_thread_record", + None, + )); + } + let output_dir = self + .file_access_for_request(&meta)? + .confine_dir(args.output_dir.as_deref()) + .map_err(|reason| McpError::invalid_params(reason, None))?; + let progress = make_progress_fn(&meta, &client); + let cancel = make_cancel_fn(ct); + let result = + export_thread_record(self, &args, &output_dir, progress.callback(), Some(&cancel)) + .await; + progress.finish().await; + match result { + Ok(data) => compact_result(data), + Err(error) => Ok(tool_error_result(&error)), + } + } + #[tool( name = "create_draft", output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid create_draft output schema"), @@ -476,6 +1082,7 @@ impl AgentMailServer { )] async fn create_draft_tool( &self, + meta: Meta, Parameters(args): Parameters, ) -> Result { if args.to.is_empty() && args.cc.is_empty() && args.bcc.is_empty() { @@ -484,147 +1091,120 @@ impl AgentMailServer { None, )); } - if args.attachments.len() > MAX_DRAFT_ATTACHMENTS { - return Err(McpError::invalid_params( - format!("attachments supports at most {MAX_DRAFT_ATTACHMENTS} files"), + let file_access = self.file_access_for_request(&meta)?; + let loaded = load_draft_attachments(&file_access, &args.attachments).await?; + + match self + .agentmail + .create_draft_with_headers( + &args.account, + args.subject.trim(), + &args.body, + &args.to, + &args.cc, + &args.bcc, + &args.reply_to, + args.in_reply_to.as_deref(), + &args.references, + &loaded, None, - )); + ) + .await + { + Ok(data) => compact_result(CreateDraftOutput::from(data)), + Err(e) => Ok(tool_error_result(&e)), } + } - // Resolve and stat every attachment before reading the first byte, so - // an oversized aggregate cannot leave a large partially loaded draft. - let mut preflight = Vec::with_capacity(args.attachments.len()); - let mut preflight_total = 0_u64; - for (index, attachment) in args.attachments.iter().enumerate() { - let safe_path = self - .file_access - .confine_read(&attachment.path) - .map_err(|reason| { - McpError::invalid_params(format!("attachment #{}: {reason}", index + 1), None) - })?; - let file = tokio::fs::File::open(&safe_path).await.map_err(|error| { - McpError::invalid_params( - format!( - "Failed to open attachment #{} at '{}': {error}", - index + 1, - attachment.path - ), - None, - ) - })?; - let metadata = file.metadata().await.map_err(|error| { - McpError::invalid_params( - format!( - "Failed to inspect attachment #{} at '{}': {error}", - index + 1, - attachment.path - ), - None, - ) - })?; - if !metadata.is_file() { - return Err(McpError::invalid_params( - format!("attachment #{} is not a regular file", index + 1), - None, - )); - } - let size = metadata.len(); - if size > MAX_DRAFT_ATTACHMENT_BYTES { - return Err(McpError::invalid_params( - format!( - "attachment #{} is {size} bytes; maximum per file is {MAX_DRAFT_ATTACHMENT_BYTES} bytes", - index + 1 - ), - None, - )); - } - preflight_total = preflight_total.checked_add(size).ok_or_else(|| { - McpError::invalid_params("attachment aggregate size overflow", None) - })?; - if preflight_total > MAX_DRAFT_ATTACHMENT_TOTAL_BYTES { - return Err(McpError::invalid_params( - format!( - "attachments total {preflight_total} bytes; aggregate maximum is {MAX_DRAFT_ATTACHMENT_TOTAL_BYTES} bytes" - ), - None, - )); - } - preflight.push((size, file)); + #[tool( + name = "create_reply_draft", + output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid create_reply_draft output schema"), + description = "Create a reply or reply-all draft from one live message identity. Requires mailbox, uid, and expectedUidValidity from the same discovery result. Uses Reply-To before From, excludes the account's own primary address and aliases, adds RFC In-Reply-To and References when the source has Message-ID, never infers Bcc, and saves only to Drafts; it never sends mail.", + annotations( + title = "Create Reply Draft", + read_only_hint = false, + destructive_hint = false + ) + )] + async fn create_reply_draft_tool( + &self, + meta: Meta, + Parameters(args): Parameters, + ) -> Result { + if args.mailbox.trim().is_empty() { + return Err(McpError::invalid_params("mailbox is required", None)); } - - let mut loaded: Vec = Vec::with_capacity(args.attachments.len()); - let mut loaded_total = 0_u64; - for (i, (a, (preflight_size, file))) in args.attachments.iter().zip(preflight).enumerate() { - let mut data = Vec::with_capacity(preflight_size as usize); - file.take(MAX_DRAFT_ATTACHMENT_BYTES + 1) - .read_to_end(&mut data) - .await - .map_err(|error| { - McpError::invalid_params( - format!( - "Failed to read attachment #{} at '{}': {error}", - i + 1, - a.path - ), - None, - ) - })?; - if data.len() as u64 > MAX_DRAFT_ATTACHMENT_BYTES { - return Err(McpError::invalid_params( - format!( - "attachment #{} grew beyond the {MAX_DRAFT_ATTACHMENT_BYTES}-byte limit while being read", - i + 1 - ), - None, - )); - } - loaded_total = loaded_total.checked_add(data.len() as u64).ok_or_else(|| { - McpError::invalid_params("attachment aggregate size overflow", None) - })?; - if loaded_total > MAX_DRAFT_ATTACHMENT_TOTAL_BYTES { - return Err(McpError::invalid_params( - format!( - "attachments grew beyond the {MAX_DRAFT_ATTACHMENT_TOTAL_BYTES}-byte aggregate limit while being read" - ), - None, - )); - } - let filename = a - .filename - .clone() - .or_else(|| { - std::path::Path::new(&a.path) - .file_name() - .and_then(|n| n.to_str()) - .map(|s| s.to_string()) - }) - .unwrap_or_else(|| format!("attachment-{}", i + 1)); - let content_type = a - .content_type - .clone() - .unwrap_or_else(|| guess_content_type(&filename)); - loaded.push(DraftAttachment { - filename, - content_type, - data, - }); + let file_access = self.file_access_for_request(&meta)?; + let loaded = load_draft_attachments(&file_access, &args.attachments).await?; + match self + .agentmail + .create_reply_draft( + &args.account, + args.mailbox.trim(), + args.uid, + args.expected_uid_validity, + reply_mode(args.mode), + args.subject.as_deref(), + &args.body, + &args.bcc, + &args.reply_to, + &loaded, + ) + .await + { + Ok(data) => compact_result(CreateDraftOutput::from(data)), + Err(error) => Ok(tool_error_result(&error)), } + } + #[tool( + name = "update_draft", + output_schema = rmcp::handler::server::tool::schema_for_output::().expect("valid update_draft output schema"), + description = "Atomically replace one live IMAP draft with a complete new draft specification. Requires mailbox, uid, and expectedUidValidity; verifies the target still has the \\Draft flag and requires RFC 8508 REPLACE. Refuses to emulate replacement with APPEND+DELETE, because that can duplicate drafts. Attachments are a complete replacement list, and this tool never sends mail.", + annotations( + title = "Update Draft Atomically", + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false + ) + )] + async fn update_draft_tool( + &self, + meta: Meta, + Parameters(args): Parameters, + ) -> Result { + if args.mailbox.trim().is_empty() { + return Err(McpError::invalid_params("mailbox is required", None)); + } + if args.to.is_empty() && args.cc.is_empty() && args.bcc.is_empty() { + return Err(McpError::invalid_params( + "At least one recipient (to, cc, or bcc) is required", + None, + )); + } + let file_access = self.file_access_for_request(&meta)?; + let loaded = load_draft_attachments(&file_access, &args.attachments).await?; match self .agentmail - .create_draft( + .update_draft( &args.account, + args.mailbox.trim(), + args.uid, + args.expected_uid_validity, args.subject.trim(), &args.body, &args.to, &args.cc, &args.bcc, + &args.reply_to, + args.in_reply_to.as_deref(), + &args.references, &loaded, ) .await { - Ok(data) => compact_result(CreateDraftOutput::from(data)), - Err(e) => Ok(tool_error_result(&e)), + Ok(data) => compact_result(UpdateDraftOutput::from(data)), + Err(error) => Ok(tool_error_result(&error)), } } @@ -1146,4 +1726,25 @@ mod tests { ); assert_eq!(guess_content_type("README"), "application/octet-stream"); } + + #[cfg(unix)] + #[tokio::test] + async fn thread_record_directories_are_private_when_created() { + use std::os::unix::fs::PermissionsExt as _; + + let path = std::env::temp_dir().join(format!( + "agentmail-private-record-dir-{}", + uuid::Uuid::new_v4() + )); + super::create_private_dir(&path) + .await + .expect("create private dir"); + let mode = std::fs::metadata(&path) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o700); + std::fs::remove_dir(&path).expect("cleanup private dir"); + } } diff --git a/src/mcp/wire.rs b/src/mcp/wire.rs index 8ac43cd..dc5a01f 100644 --- a/src/mcp/wire.rs +++ b/src/mcp/wire.rs @@ -23,6 +23,9 @@ const MAX_LIST_SENDER_PREVIEW: usize = 5; /// channels. pub(super) trait WireOutput: Serialize {} +impl WireOutput for crate::ThreadRecordPreviewResponse {} +impl WireOutput for crate::ThreadRecordExportResponse {} + /// Construct a structured value plus the same complete JSON in a text block. /// /// Some MCP hosts still render only `content`, so truncating or summarizing @@ -106,7 +109,7 @@ fn collect_resource_uris(value: &serde_json::Value, out: &mut Vec) { fn message_resource_links(structured: &serde_json::Value) -> Vec { use super::resources::{ EMAIL_BODY_MIME, EMAIL_BODY_NAME, EMAIL_BODY_TITLE, EMAIL_INFO_MIME, EMAIL_INFO_NAME, - EMAIL_INFO_TITLE, + EMAIL_INFO_TITLE, assistant_annotations, }; use rmcp::model::Resource; @@ -119,7 +122,8 @@ fn message_resource_links(structured: &serde_json::Value) -> Vec { ContentBlock::ResourceLink( Resource::new(uri, EMAIL_BODY_NAME) .with_title(EMAIL_BODY_TITLE) - .with_mime_type(EMAIL_BODY_MIME), + .with_mime_type(EMAIL_BODY_MIME) + .with_annotations(assistant_annotations(0.8)), ), ContentBlock::ResourceLink( Resource::new(info, EMAIL_INFO_NAME) @@ -128,7 +132,8 @@ fn message_resource_links(structured: &serde_json::Value) -> Vec { "Metadata plus the sibling headers/source URIs and the \ attachment inventory for this message.", ) - .with_mime_type(EMAIL_INFO_MIME), + .with_mime_type(EMAIL_INFO_MIME) + .with_annotations(assistant_annotations(0.5)), ), ] }) @@ -1545,6 +1550,80 @@ impl From for CreateMailboxOutput { impl WireOutput for CreateMailboxOutput {} +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(inline)] +pub(super) struct MailboxMutationPreflightOutput { + pub(super) message_count: u32, + pub(super) roles: Vec, + pub(super) descendants: Vec, + pub(super) confirmations_required: Vec, +} + +impl From for MailboxMutationPreflightOutput { + fn from(value: crate::MailboxMutationPreflight) -> Self { + Self { + message_count: value.message_count, + roles: value.roles, + descendants: value.descendants, + confirmations_required: value.confirmations_required, + } + } +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub(super) struct RenameMailboxOutput { + pub(super) account: String, + pub(super) mailbox: String, + pub(super) new_mailbox: String, + pub(super) preview: bool, + pub(super) renamed: bool, + pub(super) preflight: MailboxMutationPreflightOutput, +} + +impl From for RenameMailboxOutput { + fn from(value: crate::RenameMailboxResponse) -> Self { + Self { + account: value.account, + mailbox: value.mailbox, + new_mailbox: value.new_mailbox, + preview: value.preview, + renamed: value.renamed, + preflight: value.preflight.into(), + } + } +} + +impl WireOutput for RenameMailboxOutput {} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub(super) struct DeleteMailboxOutput { + pub(super) account: String, + pub(super) mailbox: String, + pub(super) preview: bool, + pub(super) deleted: bool, + pub(super) already_missing: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) preflight: Option, +} + +impl From for DeleteMailboxOutput { + fn from(value: crate::DeleteMailboxResponse) -> Self { + Self { + account: value.account, + mailbox: value.mailbox, + preview: value.preview, + deleted: value.deleted, + already_missing: value.already_missing, + preflight: value.preflight.map(Into::into), + } + } +} + +impl WireOutput for DeleteMailboxOutput {} + #[derive(Debug, Clone, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub(super) struct CreateDraftOutput { @@ -1552,6 +1631,10 @@ pub(super) struct CreateDraftOutput { pub(super) account: String, pub(super) drafts_mailbox: String, pub(super) attachment_count: usize, + pub(super) reply_to_count: usize, + pub(super) threading_applied: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) warning: Option, /// UIDVALIDITY of the drafts mailbox, when the server let the new /// draft's identity be recovered after APPEND. #[serde(skip_serializing_if = "Option::is_none")] @@ -1582,6 +1665,9 @@ impl From for CreateDraftOutput { account: value.account, drafts_mailbox: value.drafts_mailbox, attachment_count: value.attachments.len(), + reply_to_count: value.recipients.reply_to.len(), + threading_applied: value.threading_applied, + warning: value.warning, uid_validity: value.uid_validity, uid: value.uid, resource_uri, @@ -1591,6 +1677,52 @@ impl From for CreateDraftOutput { impl WireOutput for CreateDraftOutput {} +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub(super) struct UpdateDraftOutput { + pub(super) updated: bool, + pub(super) account: String, + pub(super) drafts_mailbox: String, + #[schemars(range(min = 1))] + pub(super) previous_uid_validity: u32, + #[schemars(range(min = 1))] + pub(super) previous_uid: u32, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + pub(super) uid_validity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + pub(super) uid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) resource_uri: Option, +} + +impl From for UpdateDraftOutput { + fn from(value: crate::UpdateDraftResponse) -> Self { + let resource_uri = match (value.uid_validity, value.uid) { + (Some(uid_validity), Some(uid)) => Some(message_resource_uri( + &value.account, + &value.drafts_mailbox, + uid_validity, + uid, + )), + _ => None, + }; + Self { + updated: value.updated, + account: value.account, + drafts_mailbox: value.drafts_mailbox, + previous_uid_validity: value.previous_uid_validity, + previous_uid: value.previous_uid, + uid_validity: value.uid_validity, + uid: value.uid, + resource_uri, + } + } +} + +impl WireOutput for UpdateDraftOutput {} + #[derive(Debug, Clone, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] #[schemars(inline)] diff --git a/src/record.rs b/src/record.rs new file mode 100644 index 0000000..3eadb7e --- /dev/null +++ b/src/record.rs @@ -0,0 +1,584 @@ +//! Deterministic presentation rendering and structural checks for thread records. +//! +//! The `.eml` files remain the lossless evidence. The PDF is a readable index +//! and chronology whose hash table points back to those exact source bytes. + +use mail_parser::{MessageParser, MimeHeaders as _}; +use printpdf::{ + Color, Mm, Op, ParsedFont, PdfDocument, PdfFontHandle, PdfPage, PdfParseOptions, + PdfSaveOptions, Point, Pt, Rgb, TextItem, +}; +use sha2::{Digest as _, Sha256}; + +use crate::{ + AgentmailError, AttachmentInfo, Result, ThreadRecordFile, ThreadRecordPreviewResponse, +}; + +const NOTO_SANS: &[u8] = include_bytes!("../assets/fonts/noto-sans/NotoSans-Variable.ttf"); +const BODY_PRESENTATION_CHAR_LIMIT: usize = 100_000; +const PAGE_WIDTH_MM: f32 = 210.0; +const PAGE_HEIGHT_MM: f32 = 297.0; +const PAGE_LEFT_MM: f32 = 18.0; +const PAGE_TOP_MM: f32 = 278.0; +const PAGE_BOTTOM_MM: f32 = 18.0; + +#[derive(Debug, Clone)] +pub(crate) struct RecordPresentationMessage { + pub body: String, + pub body_truncated: bool, + pub attachments: Vec, +} + +#[derive(Debug, Clone, Copy)] +enum LineStyle { + Title, + Heading, + Subheading, + Body, + Mono, + Muted, +} + +impl LineStyle { + fn size(self) -> f32 { + match self { + Self::Title => 24.0, + Self::Heading => 16.0, + Self::Subheading => 12.0, + Self::Body => 9.5, + Self::Mono => 8.0, + Self::Muted => 8.5, + } + } + + fn line_height_mm(self) -> f32 { + self.size() * 0.48 + } + + fn wrap_width(self) -> usize { + match self { + Self::Title => 42, + Self::Heading => 60, + Self::Subheading => 76, + Self::Body => 96, + Self::Mono => 108, + Self::Muted => 104, + } + } + + fn color(self) -> Color { + let (r, g, b) = match self { + Self::Title => (0.08, 0.12, 0.2), + Self::Heading => (0.12, 0.22, 0.38), + Self::Subheading => (0.16, 0.31, 0.5), + Self::Body => (0.12, 0.13, 0.16), + Self::Mono => (0.18, 0.2, 0.24), + Self::Muted => (0.38, 0.4, 0.45), + }; + Color::Rgb(Rgb { + r, + g, + b, + icc_profile: None, + }) + } +} + +#[derive(Debug, Clone)] +struct PresentationLine { + text: String, + style: LineStyle, + gap_before_mm: f32, +} + +pub(crate) fn sha256_bytes(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +pub(crate) fn analyze_message( + raw: &[u8], + uid: u32, + body_char_limit: usize, +) -> Result { + let parsed = MessageParser::default().parse(raw).ok_or_else(|| { + AgentmailError::Parse(format!("failed to parse saved RFC822 source for UID {uid}")) + })?; + let body = if let Some(html) = parsed.body_html(0) { + crate::content::html_to_markdown(&html) + } else if let Some(text) = parsed.body_text(0) { + crate::content::plain_to_markdown(&text) + } else { + "[No readable text or HTML body was present.]".to_string() + }; + let (body, body_truncated) = crate::content::truncate_for_context( + &body, + body_char_limit.clamp(1, BODY_PRESENTATION_CHAR_LIMIT), + ); + let attachments = parsed + .attachments() + .map(|part| { + let content_type = part + .content_type() + .map(|content_type| { + let mut value = content_type.c_type.to_string(); + if let Some(subtype) = &content_type.c_subtype { + value.push('/'); + value.push_str(subtype); + } + value + }) + .unwrap_or_else(|| "application/octet-stream".to_string()); + AttachmentInfo { + name: part.attachment_name().map(str::to_string), + content_type, + size: part.contents().len(), + content_id: part.content_id().map(str::to_string), + } + }) + .collect(); + Ok(RecordPresentationMessage { + body, + body_truncated, + attachments, + }) +} + +pub(crate) fn render_thread_record_pdf( + purpose: &str, + generated_at: &str, + preview: &ThreadRecordPreviewResponse, + files: &[ThreadRecordFile], + presentations: &[RecordPresentationMessage], + limitations: &[String], +) -> Result> { + if files.len() != preview.messages.len() || presentations.len() != files.len() { + return Err(AgentmailError::Other( + "thread record renderer received mismatched message collections".to_string(), + )); + } + + let mut lines = Vec::new(); + push_line(&mut lines, "AgentMail Thread Record", LineStyle::Title, 0.0); + push_line( + &mut lines, + "Readable presentation backed by exact RFC822 source files", + LineStyle::Subheading, + 2.0, + ); + push_line( + &mut lines, + format!("Purpose: {purpose}"), + LineStyle::Body, + 7.0, + ); + push_line( + &mut lines, + format!("Generated: {generated_at}"), + LineStyle::Muted, + 0.0, + ); + push_line( + &mut lines, + format!("Account: {}", preview.account), + LineStyle::Muted, + 0.0, + ); + push_line( + &mut lines, + format!("Selection digest: {}", preview.selection_digest), + LineStyle::Mono, + 0.0, + ); + push_line(&mut lines, "Why this is a record", LineStyle::Heading, 9.0); + push_line( + &mut lines, + "The bundle preserves each selected message as exact RFC822 bytes, identifies every source by mailbox + UIDVALIDITY + UID, hashes each source with SHA-256, includes contemporaneous DKIM results, and records the deterministic selection method. The PDF is a presentation copy; the .eml files and manifest are the integrity-bearing artifacts.", + LineStyle::Body, + 1.0, + ); + push_line(&mut lines, "Selection method", LineStyle::Heading, 7.0); + push_line(&mut lines, &preview.rationale, LineStyle::Body, 1.0); + push_line( + &mut lines, + format!( + "Seed: {} / UIDVALIDITY {} / UID {}", + preview.seed.mailbox, preview.seed.uid_validity, preview.seed.uid + ), + LineStyle::Mono, + 1.0, + ); + push_line(&mut lines, "Limitations", LineStyle::Heading, 7.0); + for limitation in limitations { + push_line(&mut lines, format!("• {limitation}"), LineStyle::Muted, 0.5); + } + + push_line(&mut lines, "Chronology", LineStyle::Heading, 10.0); + for (index, ((message, file), presentation)) in preview + .messages + .iter() + .zip(files) + .zip(presentations) + .enumerate() + { + push_line( + &mut lines, + format!( + "{}. {}", + index + 1, + empty_fallback(&message.subject, "(no subject)") + ), + LineStyle::Heading, + 10.0, + ); + push_line( + &mut lines, + format!( + "Date: {}", + message + .date + .map(|date| date.to_rfc3339()) + .unwrap_or_else(|| "not present".to_string()) + ), + LineStyle::Body, + 1.0, + ); + push_line( + &mut lines, + format!("From: {}", empty_fallback(&message.from, "not present")), + LineStyle::Body, + 0.0, + ); + push_line( + &mut lines, + format!( + "Storage identity: {} / UIDVALIDITY {} / UID {}", + message.identity.mailbox, message.identity.uid_validity, message.identity.uid + ), + LineStyle::Mono, + 0.0, + ); + push_line( + &mut lines, + format!( + "Message-ID: {}", + message.message_id.as_deref().unwrap_or("not present") + ), + LineStyle::Mono, + 0.0, + ); + push_line( + &mut lines, + format!( + "In-Reply-To: {}", + message.in_reply_to.as_deref().unwrap_or("not present") + ), + LineStyle::Mono, + 0.0, + ); + push_line( + &mut lines, + format!( + "References: {}", + if message.references.is_empty() { + "not present".to_string() + } else { + message.references.join(" ") + } + ), + LineStyle::Mono, + 0.0, + ); + for basis in &message.selection_basis { + push_line( + &mut lines, + format!("Selection basis: {basis}"), + LineStyle::Muted, + 0.0, + ); + } + push_line( + &mut lines, + format!("Source: {} · {} bytes", file.filename, file.bytes), + LineStyle::Mono, + 1.0, + ); + push_line( + &mut lines, + format!("SHA-256: {}", file.sha256), + LineStyle::Mono, + 0.0, + ); + push_line( + &mut lines, + format!( + "DKIM: {}{}", + file.dkim.result, + file.dkim + .domain + .as_deref() + .map(|domain| format!(" ({domain})")) + .unwrap_or_default() + ), + LineStyle::Muted, + 0.0, + ); + push_line( + &mut lines, + "Attachment inventory", + LineStyle::Subheading, + 5.0, + ); + if presentation.attachments.is_empty() { + push_line(&mut lines, "None", LineStyle::Muted, 0.0); + } else { + for attachment in &presentation.attachments { + push_line( + &mut lines, + format!( + "• {} · {} · {} bytes", + attachment.name.as_deref().unwrap_or("unnamed"), + attachment.content_type, + attachment.size + ), + LineStyle::Body, + 0.0, + ); + } + } + push_line(&mut lines, "Readable body", LineStyle::Subheading, 5.0); + push_line(&mut lines, &presentation.body, LineStyle::Body, 0.0); + if presentation.body_truncated { + push_line( + &mut lines, + "[Presentation body truncated; the complete body remains in the hashed .eml source.]", + LineStyle::Muted, + 1.0, + ); + } + } + + push_line(&mut lines, "Integrity table", LineStyle::Heading, 10.0); + for file in files { + push_line( + &mut lines, + format!("{} {}", file.sha256, file.filename), + LineStyle::Mono, + 0.0, + ); + } + + render_lines(lines) +} + +pub(crate) fn verify_pdf(bytes: &[u8]) -> Result { + let mut warnings = Vec::new(); + let parsed = PdfDocument::parse( + bytes, + &PdfParseOptions { + fail_on_error: true, + }, + &mut warnings, + ) + .map_err(|error| AgentmailError::Other(format!("generated PDF did not reopen: {error}")))?; + if parsed.pages.is_empty() { + return Err(AgentmailError::Other( + "generated PDF reopened with no pages".to_string(), + )); + } + Ok(parsed.pages.len()) +} + +fn render_lines(lines: Vec) -> Result> { + let mut font_warnings = Vec::new(); + let font = ParsedFont::from_bytes(NOTO_SANS, 0, &mut font_warnings).ok_or_else(|| { + AgentmailError::Other("embedded Noto Sans font could not be parsed".to_string()) + })?; + let mut document = PdfDocument::new("AgentMail Thread Record"); + let font_id = document.add_font(&font); + let font_handle = PdfFontHandle::External(font_id); + let mut pages = Vec::new(); + let mut ops = Vec::new(); + let mut y = PAGE_TOP_MM; + let mut page_number = 1usize; + + for line in lines { + let needed = line.gap_before_mm + line.style.line_height_mm(); + if y - needed < PAGE_BOTTOM_MM { + finish_page(&mut ops, &font_handle, page_number); + pages.push(PdfPage::new(Mm(PAGE_WIDTH_MM), Mm(PAGE_HEIGHT_MM), ops)); + ops = Vec::new(); + y = PAGE_TOP_MM; + page_number += 1; + } + y -= line.gap_before_mm; + ops.push(Op::StartTextSection); + ops.push(Op::SetTextCursor { + pos: Point::new(Mm(PAGE_LEFT_MM), Mm(y)), + }); + ops.push(Op::SetFont { + font: font_handle.clone(), + size: Pt(line.style.size()), + }); + ops.push(Op::SetFillColor { + col: line.style.color(), + }); + ops.push(Op::ShowText { + items: vec![TextItem::Text(line.text)], + }); + ops.push(Op::EndTextSection); + y -= line.style.line_height_mm(); + } + finish_page(&mut ops, &font_handle, page_number); + pages.push(PdfPage::new(Mm(PAGE_WIDTH_MM), Mm(PAGE_HEIGHT_MM), ops)); + + let mut save_warnings = Vec::new(); + let bytes = document + .with_pages(pages) + .save(&PdfSaveOptions::default(), &mut save_warnings); + if bytes.is_empty() { + return Err(AgentmailError::Other( + "PDF renderer returned an empty document".to_string(), + )); + } + Ok(bytes) +} + +fn finish_page(ops: &mut Vec, font: &PdfFontHandle, page_number: usize) { + ops.push(Op::StartTextSection); + ops.push(Op::SetTextCursor { + pos: Point::new(Mm(180.0), Mm(9.0)), + }); + ops.push(Op::SetFont { + font: font.clone(), + size: Pt(8.0), + }); + ops.push(Op::SetFillColor { + col: LineStyle::Muted.color(), + }); + ops.push(Op::ShowText { + items: vec![TextItem::Text(format!("Page {page_number}"))], + }); + ops.push(Op::EndTextSection); +} + +fn push_line( + output: &mut Vec, + text: impl AsRef, + style: LineStyle, + gap_before_mm: f32, +) { + let mut first = true; + for source_line in text.as_ref().replace('\r', "").split('\n') { + let wrapped = wrap_text(source_line, style.wrap_width()); + if wrapped.is_empty() { + output.push(PresentationLine { + text: String::new(), + style, + gap_before_mm: if first { gap_before_mm } else { 0.0 }, + }); + first = false; + continue; + } + for line in wrapped { + output.push(PresentationLine { + text: sanitize_pdf_text(&line), + style, + gap_before_mm: if first { gap_before_mm } else { 0.0 }, + }); + first = false; + } + } +} + +fn wrap_text(value: &str, width: usize) -> Vec { + if value.is_empty() { + return Vec::new(); + } + let mut lines = Vec::new(); + let mut current = String::new(); + for word in value.split_whitespace() { + if current.chars().count() + usize::from(!current.is_empty()) + word.chars().count() + <= width + { + if !current.is_empty() { + current.push(' '); + } + current.push_str(word); + continue; + } + if !current.is_empty() { + lines.push(std::mem::take(&mut current)); + } + let mut chunk = String::new(); + for character in word.chars() { + chunk.push(character); + if chunk.chars().count() == width { + lines.push(std::mem::take(&mut chunk)); + } + } + current = chunk; + } + if !current.is_empty() { + lines.push(current); + } + lines +} + +fn sanitize_pdf_text(value: &str) -> String { + value + .chars() + .map(|character| { + if character == '\t' { + ' ' + } else if character.is_control() { + '�' + } else { + character + } + }) + .collect() +} + +fn empty_fallback<'a>(value: &'a str, fallback: &'a str) -> &'a str { + if value.trim().is_empty() { + fallback + } else { + value + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wraps_long_unbroken_values_without_losing_characters() { + let source = "a".repeat(23); + let wrapped = wrap_text(&source, 10); + assert_eq!(wrapped, vec!["a".repeat(10), "a".repeat(10), "a".repeat(3)]); + assert_eq!(wrapped.concat(), source); + } + + #[test] + fn presentation_analysis_honors_the_per_record_body_budget() { + let raw = b"From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: bounded\r\nMessage-ID: \r\nContent-Type: text/plain; charset=utf-8\r\n\r\nabcdefghij"; + let presentation = analyze_message(raw, 7, 4).expect("analyze message"); + assert!(presentation.body.starts_with("abcd")); + assert!(presentation.body_truncated); + } + + #[test] + fn embedded_font_builds_a_pdf_that_reopens() { + let pdf = render_lines(vec![PresentationLine { + text: "Evidence — こんにちは".to_string(), + style: LineStyle::Body, + gap_before_mm: 0.0, + }]) + .expect("render PDF"); + assert!(pdf.starts_with(b"%PDF-")); + assert_eq!(verify_pdf(&pdf).expect("parse PDF"), 1); + } +} diff --git a/src/types.rs b/src/types.rs index 7471e20..6298ae4 100644 --- a/src/types.rs +++ b/src/types.rs @@ -819,6 +819,42 @@ pub struct CreateMailboxResponse { pub already_exists: bool, } +/// Live mailbox facts returned before a rename or delete. +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(inline)] +pub struct MailboxMutationPreflight { + pub message_count: u32, + pub roles: Vec, + pub descendants: Vec, + pub confirmations_required: Vec, +} + +/// Response for guarded mailbox rename. +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct RenameMailboxResponse { + pub account: String, + pub mailbox: String, + pub new_mailbox: String, + pub preview: bool, + pub renamed: bool, + pub preflight: MailboxMutationPreflight, +} + +/// Response for guarded mailbox deletion. +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DeleteMailboxResponse { + pub account: String, + pub mailbox: String, + pub preview: bool, + pub deleted: bool, + pub already_missing: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub preflight: Option, +} + /// Recipients for a draft email. #[derive(Debug, Clone, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] @@ -827,6 +863,7 @@ pub struct DraftRecipients { pub to: Vec, pub cc: Vec, pub bcc: Vec, + pub reply_to: Vec, } /// Attachment data for creating drafts (internal; bytes already loaded). @@ -846,6 +883,13 @@ pub struct CreateDraftResponse { pub drafts_mailbox: String, pub subject: String, pub recipients: DraftRecipients, + #[serde(skip_serializing_if = "Option::is_none")] + pub in_reply_to: Option, + #[serde(default)] + pub references: Vec, + pub threading_applied: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub warning: Option, #[serde(default)] pub attachments: Vec, /// UIDVALIDITY of the drafts mailbox when the new draft's identity could @@ -857,6 +901,28 @@ pub struct CreateDraftResponse { pub uid: Option, } +/// Which recipients a reply draft should include. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplyMode { + Reply, + ReplyAll, +} + +/// Response for an atomic RFC 8508 draft replacement. +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateDraftResponse { + pub updated: bool, + pub account: String, + pub drafts_mailbox: String, + pub previous_uid_validity: u32, + pub previous_uid: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub uid_validity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub uid: Option, +} + /// A downloaded attachment file. #[derive(Debug, Clone, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] @@ -922,6 +988,85 @@ pub struct DownloadedMessageSource { pub dkim: DkimVerification, } +/// One exact storage identity selected for a thread record. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(inline)] +pub struct ThreadRecordMessage { + pub identity: MailboxMessageIdentity, + #[serde(skip_serializing_if = "Option::is_none")] + pub message_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub in_reply_to: Option, + #[serde(default)] + pub references: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub date: Option>, + pub from: String, + pub subject: String, + /// Exact RFC header relationships that caused this identity to be selected. + pub selection_basis: Vec, +} + +/// Preview of the exact Message-ID graph that an evidence-record export will use. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ThreadRecordPreviewResponse { + pub account: String, + pub seed: MailboxMessageIdentity, + pub strategy: String, + pub rationale: String, + pub messages: Vec, + pub selection_digest: String, + pub confirmation_required: bool, + pub truncated: bool, + #[serde(default)] + pub warnings: Vec, +} + +/// One immutable RFC822 source entry in a completed thread-record bundle. +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(inline)] +pub struct ThreadRecordFile { + pub identity: MailboxMessageIdentity, + pub filename: String, + pub bytes: usize, + pub sha256: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub message_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub date: Option>, + #[serde(rename = "from", skip_serializing_if = "Option::is_none")] + pub from_header: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option, + pub dkim: DkimVerification, +} + +/// Completed, structurally verified evidence-record bundle. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ThreadRecordExportResponse { + /// True only after all EML files, the PDF, and the manifest were reopened, + /// parsed where applicable, and hash-checked. + pub recorded: bool, + /// Means the packet is organized as a submission record, not that a court + /// or other recipient has accepted it or that it is legally admissible. + pub submittable: bool, + pub submission_explanation: String, + pub account: String, + pub purpose: String, + pub selection_digest: String, + pub message_count: usize, + pub bundle_path: String, + pub pdf_path: String, + pub manifest_path: String, + pub total_bytes: u64, + #[serde(default)] + pub limitations: Vec, +} + /// Response for get_message_source. #[derive(Debug, Clone, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] diff --git a/tests/mcp_integration.rs b/tests/mcp_integration.rs index b2bf62e..dacfddb 100644 --- a/tests/mcp_integration.rs +++ b/tests/mcp_integration.rs @@ -307,13 +307,13 @@ async fn initialize_reports_capabilities_and_identity() { } #[tokio::test] -async fn tools_list_has_31_annotated_tools() { +async fn tools_list_has_37_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(), - 31, + 37, "tool count drifted — update docs and tests" ); @@ -830,36 +830,46 @@ async fn discovery_outputs_have_safe_complete_message_identities() { } #[tokio::test] -async fn resources_templates_list_five_uidvalidity_safe_templates() { +async fn resources_templates_list_six_annotated_templates() { let mut client = McpClient::start().await; let resp = client.request("resources/templates/list", json!({})).await; let templates = resp["result"]["resourceTemplates"] .as_array() .expect("resourceTemplates array"); - assert_eq!(templates.len(), 5, "template count drifted: {templates:#?}"); + assert_eq!(templates.len(), 6, "template count drifted: {templates:#?}"); - let body = &templates[0]; + let mailbox = &templates[0]; + assert_eq!( + mailbox["uriTemplate"].as_str(), + Some("email://{account}/{mailbox}{?offset,limit}") + ); + assert_eq!(mailbox["annotations"]["priority"].as_f64(), Some(0.8)); + assert_eq!(mailbox["annotations"]["audience"], json!(["assistant"])); + + let body = &templates[1]; assert_eq!( body["uriTemplate"].as_str(), Some("email://{account}/{mailbox}/{uidValidity}/{uid}") ); assert_eq!(body["mimeType"].as_str(), Some("text/markdown")); - let headers = &templates[1]; + assert_eq!(body["annotations"]["priority"].as_f64(), Some(0.8)); + + let headers = &templates[2]; assert_eq!( headers["uriTemplate"].as_str(), Some("email://{account}/{mailbox}/{uidValidity}/{uid}/headers") ); assert_eq!(headers["mimeType"].as_str(), Some("text/rfc822-headers")); - let source = &templates[2]; + let source = &templates[3]; assert_eq!( source["uriTemplate"].as_str(), Some("email://{account}/{mailbox}/{uidValidity}/{uid}/source") ); assert_eq!(source["mimeType"].as_str(), Some("message/rfc822")); - let info = &templates[3]; + let info = &templates[4]; assert_eq!( info["uriTemplate"].as_str(), Some("email://{account}/{mailbox}/{uidValidity}/{uid}/info") @@ -873,7 +883,7 @@ async fn resources_templates_list_five_uidvalidity_safe_templates() { "info template should advertise the attachment inventory" ); - let attachment = &templates[4]; + let attachment = &templates[5]; assert_eq!( attachment["uriTemplate"].as_str(), Some("email://{account}/{mailbox}/{uidValidity}/{uid}/attachments/{index}") @@ -889,12 +899,14 @@ async fn resources_templates_list_five_uidvalidity_safe_templates() { .contains("download_attachments"), "attachment template should point large files at download_attachments" ); + assert_eq!( + attachment["annotations"]["audience"], + json!(["user", "assistant"]) + ); } #[tokio::test] -async fn resources_list_is_empty() { - // Discovery is template-only; this also pins that resources/list is - // served (not method_not_found) now that the capability is advertised. +async fn resources_list_exposes_annotated_account_roots() { let mut client = McpClient::start().await; let resp = client.request("resources/list", json!({})).await; assert!( @@ -904,14 +916,19 @@ async fn resources_list_is_empty() { let resources = resp["result"]["resources"] .as_array() .expect("resources array"); - assert!(resources.is_empty(), "expected empty list: {resources:#?}"); + assert_eq!(resources.len(), 1, "account roots drifted: {resources:#?}"); + assert_eq!(resources[0]["uri"].as_str(), Some("email://dummy")); + assert_eq!(resources[0]["annotations"]["priority"].as_f64(), Some(0.8)); + assert_eq!( + resources[0]["annotations"]["audience"], + json!(["assistant"]) + ); } #[tokio::test] async fn resources_read_malformed_uri_is_32602() { let mut client = McpClient::start().await; for uri in [ - "email://dummy/INBOX", "email://dummy/INBOX/1", "email://dummy/INBOX/0/1", "email://dummy/INBOX/1/0",