diff --git a/Cargo.lock b/Cargo.lock index 8d3b5b76..178b58ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "addr2line" -version = "0.24.1" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5fb1d8e4442bd405fdfd1dacb42792696b0cf9cb15882e5d097b742a676d375" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" dependencies = [ "gimli", ] @@ -278,7 +278,7 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", "synstructure 0.13.1", ] @@ -301,14 +301,14 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] name = "async-compression" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fec134f64e2bc57411226dfc4e52dec859ddfc7e711fc5e07b612584f000e4aa" +checksum = "7e614738943d3f68c628ae3dbce7c3daffb196665f82f8c8ea6b65de73c79429" dependencies = [ "flate2", "futures-core", @@ -325,14 +325,14 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] name = "async-stream" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" dependencies = [ "async-stream-impl", "futures-core", @@ -341,13 +341,13 @@ dependencies = [ [[package]] name = "async-stream-impl" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -358,7 +358,7 @@ checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -384,9 +384,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "aws-creds" @@ -416,9 +416,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f43644eed690f5374f1af436ecd6aea01cd201f6fbdf0178adaf6907afb2cec" +checksum = "504e3947307ac8326a5437504c517c4b56716c9d98fac0028c2acc7ca47d70ae" dependencies = [ "async-trait", "axum-core", @@ -443,9 +443,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6b8ba012a258d63c9adfa28b9ddcf66149da6f986c5b5452e629d5ee64bf00" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" dependencies = [ "async-trait", "bytes", @@ -559,7 +559,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.77", + "syn 2.0.79", "which", ] @@ -578,7 +578,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -745,7 +745,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", "syn_derive", ] @@ -865,9 +865,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.1.21" +version = "1.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07b1695e2c7e8fc85310cde85aeaab7e3097f593c91d209d3f9df76c928100f0" +checksum = "e8d9e0b4957f635b8d3da819d0db5603620467ecf1f692d22a8c2717ce27e6d8" dependencies = [ "jobserver", "libc", @@ -972,9 +972,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.18" +version = "4.5.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0956a43b323ac1afaffc053ed5c4b7c1f1800bacd1683c353aabbb752515dd3" +checksum = "7be5744db7978a28d9df86a214130d106a89ce49644cbc4e3f0c22c3fba30615" dependencies = [ "clap_builder", "clap_derive", @@ -982,9 +982,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.18" +version = "4.5.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d72166dd41634086d5803a47eb71ae740e61d84709c36f3c34110173db3961b" +checksum = "a5fbc17d3ef8278f55b282b2a2e75ae6f6c7d4bb70ed3d0382375104bfafdb4b" dependencies = [ "anstream", "anstyle", @@ -1001,7 +1001,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -1095,11 +1095,11 @@ dependencies = [ "pwhash", "rcgen 0.12.1", "regex", - "reqwest 0.12.7", + "reqwest 0.12.8", "ring 0.17.8", "rsa", - "rustls 0.23.13", - "rustls-pemfile 2.1.3", + "rustls 0.23.14", + "rustls-pemfile 2.2.0", "rustls-pki-types", "serde", "serde_json", @@ -1160,9 +1160,9 @@ dependencies = [ [[package]] name = "const_panic" -version = "0.2.9" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7782af8f90fe69a4bb41e460abe1727d493403d8b2cc43201a3a3e906b24379f" +checksum = "013b6c2c3a14d678f38cd23994b02da3a1a1b6a5d1eedddfe63a5a5f11b13a81" [[package]] name = "constant_time_eq" @@ -1380,7 +1380,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -1428,7 +1428,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -1450,7 +1450,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core 0.20.10", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -1602,7 +1602,7 @@ checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -1623,7 +1623,7 @@ dependencies = [ "darling 0.20.10", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -1633,7 +1633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4abae7035bf79b9877b779505d8cf3749285b80c43941eda66604841889451dc" dependencies = [ "derive_builder_core", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -1689,8 +1689,8 @@ dependencies = [ "proc_macros", "pwhash", "regex", - "reqwest 0.12.7", - "rustls 0.23.13", + "reqwest 0.12.8", + "rustls 0.23.14", "rustls-pki-types", "scrypt", "serde", @@ -1735,7 +1735,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -1754,7 +1754,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14b0a9ca156a0285b2768b7fcb368b231e7736d3c13eef604c5c4203057bed33" dependencies = [ "hickory-client", - "reqwest 0.12.7", + "reqwest 0.12.8", "serde", "serde_json", "serde_urlencoded", @@ -1960,7 +1960,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -1985,7 +1985,7 @@ version = "0.1.0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -2053,9 +2053,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flate2" -version = "1.0.33" +version = "1.0.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253" +checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" dependencies = [ "crc32fast", "libz-sys", @@ -2146,7 +2146,7 @@ checksum = "f8db6653cbc621a3810d95d55bd342be3e71181d6df21a4eb29ef986202d3f9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", "try_map", ] @@ -2189,7 +2189,7 @@ checksum = "e99b8b3c28ae0e84b604c75f721c21dc77afb3706076af5e8216d15fd1deaae3" dependencies = [ "frunk_proc_macro_helpers", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -2201,7 +2201,7 @@ dependencies = [ "frunk_core", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -2213,7 +2213,7 @@ dependencies = [ "frunk_core", "frunk_proc_macro_helpers", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -2224,9 +2224,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", @@ -2239,9 +2239,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -2249,15 +2249,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", @@ -2266,38 +2266,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-macro" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] name = "futures-sink" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", @@ -2375,9 +2375,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.31.0" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32085ea23f3234fc7846555e85283ba4de91e21016dc0455a16286d87a292d64" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "glob" @@ -2408,7 +2408,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.5.0", + "indexmap 2.6.0", "slab", "tokio", "tokio-util", @@ -2427,7 +2427,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.1.0", - "indexmap 2.5.0", + "indexmap 2.6.0", "slab", "tokio", "tokio-util", @@ -2453,6 +2453,12 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "hashbrown" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" + [[package]] name = "hashlink" version = "0.9.1" @@ -2683,9 +2689,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.9.4" +version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" +checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" [[package]] name = "httpdate" @@ -2768,7 +2774,7 @@ dependencies = [ "http 1.1.0", "hyper 1.4.1", "hyper-util", - "rustls 0.23.13", + "rustls 0.23.14", "rustls-pki-types", "tokio", "tokio-rustls 0.26.0", @@ -2946,7 +2952,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -3019,8 +3025,8 @@ dependencies = [ "nlp", "parking_lot", "rand", - "rustls 0.23.13", - "rustls-pemfile 2.1.3", + "rustls 0.23.14", + "rustls-pemfile 2.2.0", "store", "tokio", "tokio-rustls 0.26.0", @@ -3053,12 +3059,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown 0.14.5", + "hashbrown 0.15.0", ] [[package]] @@ -3116,9 +3122,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.10.0" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4" +checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" [[package]] name = "is-terminal" @@ -3253,7 +3259,7 @@ dependencies = [ "rasn", "rasn-cms", "rasn-pkix", - "reqwest 0.12.7", + "reqwest 0.12.8", "rev_lines", "rsa", "sequoia-openpgp", @@ -3297,7 +3303,7 @@ dependencies = [ [[package]] name = "jmap_proto" -version = "0.1.0" +version = "0.10.2" dependencies = [ "ahash 0.8.11", "fast-float", @@ -3334,7 +3340,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ee7893dab2e44ae5f9d0173f26ff4aa327c10b01b06a72b52dd9405b628640d" dependencies = [ - "indexmap 2.5.0", + "indexmap 2.6.0", ] [[package]] @@ -3569,9 +3575,9 @@ dependencies = [ [[package]] name = "lz4-sys" -version = "1.11.0" +version = "1.11.1+lz4-1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcb44a01837a858d47e5a630d2ccf304c8efcc4b83b8f9f75b7a9ee4fcc6e57d" +checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" dependencies = [ "cc", "libc", @@ -3610,7 +3616,7 @@ dependencies = [ "rand", "ring 0.17.8", "rsa", - "rustls-pemfile 2.1.3", + "rustls-pemfile 2.2.0", "serde", "serde_json", "zip", @@ -3644,7 +3650,7 @@ dependencies = [ "base64 0.22.1", "gethostname", "md5", - "rustls 0.23.13", + "rustls 0.23.14", "rustls-pki-types", "smtp-proto", "tokio", @@ -3687,8 +3693,8 @@ dependencies = [ "mail-send", "md5", "parking_lot", - "rustls 0.23.13", - "rustls-pemfile 2.1.3", + "rustls 0.23.14", + "rustls-pemfile 2.2.0", "sieve-rs", "store", "tokio", @@ -3723,7 +3729,7 @@ checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -3840,7 +3846,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", "termcolor", "thiserror", ] @@ -3868,7 +3874,7 @@ dependencies = [ "pin-project", "rand", "rustls 0.22.4", - "rustls-pemfile 2.1.3", + "rustls-pemfile 2.2.0", "serde", "serde_json", "socket2", @@ -4068,9 +4074,9 @@ checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" [[package]] name = "object" -version = "0.36.4" +version = "0.36.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" dependencies = [ "memchr", ] @@ -4095,9 +4101,12 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "82881c4be219ab5faaf2ad5e5e5ecdff8c66bd7402ca3160975c93b24961afd1" +dependencies = [ + "portable-atomic", +] [[package]] name = "opaque-debug" @@ -4128,7 +4137,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -4173,7 +4182,7 @@ dependencies = [ "bytes", "http 1.1.0", "opentelemetry", - "reqwest 0.12.7", + "reqwest 0.12.8", ] [[package]] @@ -4190,7 +4199,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost", - "reqwest 0.12.7", + "reqwest 0.12.8", "thiserror", "tokio", "tonic", @@ -4365,7 +4374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap 2.5.0", + "indexmap 2.6.0", ] [[package]] @@ -4408,7 +4417,7 @@ dependencies = [ "phf_shared 0.11.2", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -4446,7 +4455,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -4511,7 +4520,7 @@ dependencies = [ "jmap_proto", "mail-parser", "mail-send", - "rustls 0.23.13", + "rustls 0.23.14", "store", "tokio", "tokio-rustls 0.26.0", @@ -4521,9 +4530,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d30538d42559de6b034bc76fd6dd4c38961b1ee5c6c56e3808c50128fdbc22ce" +checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" [[package]] name = "postgres-protocol" @@ -4582,7 +4591,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479cf940fbbb3426c32c5d5176f62ad57549a0bb84773423ba8be9d089f5faba" dependencies = [ "proc-macro2", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -4666,7 +4675,7 @@ version = "0.1.0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -4703,7 +4712,7 @@ dependencies = [ "itertools 0.13.0", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -4802,7 +4811,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.0.0", - "rustls 0.23.13", + "rustls 0.23.14", "socket2", "thiserror", "tokio", @@ -4819,7 +4828,7 @@ dependencies = [ "rand", "ring 0.17.8", "rustc-hash 2.0.0", - "rustls 0.23.13", + "rustls 0.23.14", "slab", "thiserror", "tinyvec", @@ -5025,9 +5034,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "rand", - "rustls 0.23.13", + "rustls 0.23.14", "rustls-native-certs 0.7.3", - "rustls-pemfile 2.1.3", + "rustls-pemfile 2.2.0", "rustls-pki-types", "ryu", "sha1_smol", @@ -5041,9 +5050,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.6" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "355ae415ccd3a04315d3f8246e86d67689ea74d88d915576e1589a351062a13b" +checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" dependencies = [ "bitflags 2.6.0", ] @@ -5061,9 +5070,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.6" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" dependencies = [ "aho-corasick", "memchr", @@ -5073,9 +5082,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" dependencies = [ "aho-corasick", "memchr", @@ -5084,9 +5093,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "rend" @@ -5143,9 +5152,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.7" +version = "0.12.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8f4955649ef5c38cc7f9e8aa41761d48fb9677197daea9984dc54f56aad5e63" +checksum = "f713147fbe92361e52392c73b8c9e48c04c6625bce969ef54dc901e58e042a7b" dependencies = [ "base64 0.22.1", "bytes", @@ -5168,8 +5177,8 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.13", - "rustls-pemfile 2.1.3", + "rustls 0.23.14", + "rustls-pemfile 2.2.0", "rustls-pki-types", "serde", "serde_json", @@ -5526,9 +5535,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.13" +version = "0.23.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2dabaac7466917e566adb06783a81ca48944c6898a1b08b9374106dd671f4c8" +checksum = "415d9944693cb90382053259f89fbb077ea730ad7273047ec63b19bc9b160ba8" dependencies = [ "once_cell", "ring 0.17.8", @@ -5557,7 +5566,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" dependencies = [ "openssl-probe", - "rustls-pemfile 2.1.3", + "rustls-pemfile 2.2.0", "rustls-pki-types", "schannel", "security-framework", @@ -5574,19 +5583,18 @@ dependencies = [ [[package]] name = "rustls-pemfile" -version = "2.1.3" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "196fe16b00e106300d3e45ecfcb764fa292a535d7326a29a5875c579c7417425" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" dependencies = [ - "base64 0.22.1", "rustls-pki-types", ] [[package]] name = "rustls-pki-types" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" +checksum = "0e696e35370c65c9c541198af4543ccd580cf17fc25d8e05c5a242b202488c55" [[package]] name = "rustls-webpki" @@ -5647,9 +5655,9 @@ checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" [[package]] name = "scc" -version = "2.1.17" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c947adb109a8afce5fc9c7bf951f87f146e9147b3a6a58413105628fb1d1e66" +checksum = "836f1e0f4963ef5288b539b643b35e043e76a32d0f4e47e67febf69576527f50" dependencies = [ "sdd", ] @@ -5852,7 +5860,7 @@ checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -5861,7 +5869,7 @@ version = "1.0.128" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" dependencies = [ - "indexmap 2.5.0", + "indexmap 2.6.0", "itoa", "memchr", "ryu", @@ -5924,7 +5932,7 @@ checksum = "82fe9db325bcef1fbcde82e078a5cc4efdf787e96b3b9cf45b50b529f2083d67" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -6000,9 +6008,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "sieve-rs" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28446db92d055bb4fb05c63fcdb25462d0826acec87180c1c0e4c3aaf69cc383" +checksum = "6c9e83255107db4e04a9b36cfaa7437d9d1b853de09559cb2424c754c2975eca" dependencies = [ "ahash 0.8.11", "bincode", @@ -6098,9 +6106,9 @@ dependencies = [ "rand", "rayon", "regex", - "reqwest 0.12.7", - "rustls 0.23.13", - "rustls-pemfile 2.1.3", + "reqwest 0.12.8", + "rustls 0.23.14", + "rustls-pemfile 2.2.0", "rustls-pki-types", "serde", "serde_json", @@ -6205,7 +6213,7 @@ dependencies = [ "prettytable-rs", "pwhash", "rand", - "reqwest 0.12.7", + "reqwest 0.12.8", "rpassword", "serde", "serde_json", @@ -6252,7 +6260,7 @@ dependencies = [ "rocksdb", "rusqlite", "rust-s3", - "rustls 0.23.13", + "rustls 0.23.14", "rustls-pki-types", "serde", "serde_json", @@ -6329,9 +6337,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.77" +version = "2.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" dependencies = [ "proc-macro2", "quote", @@ -6347,7 +6355,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -6385,7 +6393,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -6470,10 +6478,10 @@ dependencies = [ "num_cpus", "pop3", "rayon", - "reqwest 0.12.7", + "reqwest 0.12.8", "ring 0.17.8", - "rustls 0.23.13", - "rustls-pemfile 2.1.3", + "rustls 0.23.14", + "rustls-pemfile 2.2.0", "rustls-pki-types", "serde", "serde_json", @@ -6505,7 +6513,7 @@ checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -6610,7 +6618,7 @@ checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -6666,7 +6674,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" dependencies = [ - "rustls 0.23.13", + "rustls 0.23.14", "rustls-pki-types", "tokio", ] @@ -6735,16 +6743,16 @@ version = "0.22.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ - "indexmap 2.5.0", + "indexmap 2.6.0", "toml_datetime", "winnow", ] [[package]] name = "tonic" -version = "0.12.2" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f6ba989e4b2c58ae83d862d3a3e27690b6e3ae630d0deb59f3697f32aa88ad" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" dependencies = [ "async-stream", "async-trait", @@ -6850,7 +6858,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -6873,7 +6881,7 @@ dependencies = [ "mail-auth", "mail-parser", "parking_lot", - "reqwest 0.12.7", + "reqwest 0.12.8", "rtrb", "serde", "serde_json", @@ -6989,9 +6997,9 @@ dependencies = [ [[package]] name = "unicode-bidi" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" +checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" [[package]] name = "unicode-ident" @@ -7010,9 +7018,9 @@ dependencies = [ [[package]] name = "unicode-properties" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ea75f83c0137a9b98608359a5f1af8144876eb67bcb1ce837368e906a9f524" +checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" [[package]] name = "unicode-script" @@ -7126,10 +7134,10 @@ dependencies = [ "rand", "rcgen 0.13.1", "regex", - "reqwest 0.12.7", + "reqwest 0.12.8", "ring 0.17.8", - "rustls 0.23.13", - "rustls-pemfile 2.1.3", + "rustls 0.23.14", + "rustls-pemfile 2.2.0", "rustls-pki-types", "serde", "serde_json", @@ -7221,7 +7229,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", "wasm-bindgen-shared", ] @@ -7255,7 +7263,7 @@ checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -7268,9 +7276,9 @@ checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" [[package]] name = "wasm-streams" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129" +checksum = "4e072d4e72f700fb3443d8fe94a39315df013eef1104903cdb0a2abd322bbecd" dependencies = [ "futures-util", "js-sys", @@ -7707,7 +7715,7 @@ checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", "synstructure 0.13.1", ] @@ -7729,7 +7737,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -7749,7 +7757,7 @@ checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", "synstructure 0.13.1", ] @@ -7770,7 +7778,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -7792,7 +7800,7 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -7811,7 +7819,7 @@ dependencies = [ "displaydoc", "flate2", "hmac 0.12.1", - "indexmap 2.5.0", + "indexmap 2.6.0", "lzma-rs", "memchr", "pbkdf2", diff --git a/crates/common/src/config/mod.rs b/crates/common/src/config/mod.rs index 1bea639a..2ed974d8 100644 --- a/crates/common/src/config/mod.rs +++ b/crates/common/src/config/mod.rs @@ -4,14 +4,19 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::sync::Arc; +use std::{str::FromStr, sync::Arc}; use arc_swap::ArcSwap; +use base64::{engine::general_purpose, Engine}; use directory::{Directories, Directory}; +use hyper::{ + header::{HeaderName, HeaderValue, AUTHORIZATION}, + HeaderMap, +}; use ring::signature::{EcdsaKeyPair, RsaKeyPair}; use store::{BlobBackend, BlobStore, FtsStore, LookupStore, Store, Stores}; use telemetry::Metrics; -use utils::config::Config; +use utils::config::{utils::AsKey, Config}; use crate::{ auth::oauth::config::OAuthConfig, expr::*, listener::tls::AcmeProviders, @@ -229,3 +234,52 @@ pub fn build_ecdsa_pem( Ok(None) => Err("No ECDSA key found in PEM".to_string()), } } + +pub(crate) fn parse_http_headers(config: &mut Config, prefix: impl AsKey) -> HeaderMap { + let prefix = prefix.as_key(); + let mut headers = HeaderMap::new(); + + for (header, value) in config + .values((&prefix, "headers")) + .map(|(_, v)| { + if let Some((k, v)) = v.split_once(':') { + Ok(( + HeaderName::from_str(k.trim()).map_err(|err| { + format!("Invalid header found in property \"{prefix}.headers\": {err}",) + })?, + HeaderValue::from_str(v.trim()).map_err(|err| { + format!("Invalid header found in property \"{prefix}.headers\": {err}",) + })?, + )) + } else { + Err(format!( + "Invalid header found in property \"{prefix}.headers\": {v}", + )) + } + }) + .collect::, String>>() + .map_err(|e| config.new_parse_error((&prefix, "headers"), e)) + .unwrap_or_default() + { + headers.insert(header, value); + } + + if let (Some(name), Some(secret)) = ( + config.value((&prefix, "auth.username")), + config.value((&prefix, "auth.secret")), + ) { + headers.insert( + AUTHORIZATION, + format!( + "Basic {}", + general_purpose::STANDARD.encode(format!("{}:{}", name, secret)) + ) + .parse() + .unwrap(), + ); + } else if let Some(token) = config.value((&prefix, "auth.token")) { + headers.insert(AUTHORIZATION, format!("Bearer {}", token).parse().unwrap()); + } + + headers +} diff --git a/crates/common/src/config/scripts.rs b/crates/common/src/config/scripts.rs index aa91a2f7..eb2b59ab 100644 --- a/crates/common/src/config/scripts.rs +++ b/crates/common/src/config/scripts.rs @@ -15,7 +15,10 @@ use sieve::{compiler::grammar::Capability, Compiler, Runtime, Sieve}; use store::Stores; use utils::config::Config; -use crate::scripts::{functions::register_functions, plugins::RegisterSievePlugins}; +use crate::scripts::{ + functions::{register_functions_trusted, register_functions_untrusted}, + plugins::RegisterSievePlugins, +}; use super::{if_block::IfBlock, smtp::SMTP_RCPT_TO_VARS, tokenizer::TokenMap}; @@ -40,6 +43,7 @@ pub struct RemoteList { impl Scripting { pub async fn parse(config: &mut Config, stores: &Stores) -> Self { // Parse untrusted compiler + let mut fnc_map_untrusted = register_functions_untrusted().register_plugins_untrusted(); let untrusted_compiler = Compiler::new() .with_max_script_size( config @@ -90,10 +94,12 @@ impl Scripting { config .property("sieve.untrusted.limits.includes") .unwrap_or(3), - ); + ) + .register_functions(&mut fnc_map_untrusted); // Parse untrusted runtime let untrusted_runtime = Runtime::new() + .with_functions(&mut fnc_map_untrusted) .with_max_nested_includes( config .property("sieve.untrusted.limits.nested-includes") @@ -141,6 +147,7 @@ impl Scripting { .unwrap_or(Duration::from_secs(7 * 86400)) .as_secs(), ) + .with_capability(Capability::Expressions) .without_capabilities( config .values("sieve.untrusted.disable-capabilities") @@ -191,7 +198,7 @@ impl Scripting { .with_env_variable("phase", "during"); // Parse trusted compiler and runtime - let mut fnc_map = register_functions().register_plugins(); + let mut fnc_map_trusted = register_functions_trusted().register_plugins_trusted(); // Allocate compiler and runtime let trusted_compiler = Compiler::new() @@ -208,7 +215,7 @@ impl Scripting { .property_or_default("sieve.trusted.no-capability-check", "true") .unwrap_or(true), ) - .register_functions(&mut fnc_map); + .register_functions(&mut fnc_map_trusted); let mut trusted_runtime = Runtime::new() .without_capabilities([ @@ -233,7 +240,7 @@ impl Scripting { .with_max_header_size(10240) .with_valid_notification_uri("mailto") .with_valid_ext_lists(stores.lookup_stores.keys().map(|k| k.to_string())) - .with_functions(&mut fnc_map) + .with_functions(&mut fnc_map_trusted) .with_max_redirects( config .property_or_default("sieve.trusted.limits.redirects", "3") diff --git a/crates/common/src/config/telemetry.rs b/crates/common/src/config/telemetry.rs index b54cc1ba..c172c576 100644 --- a/crates/common/src/config/telemetry.rs +++ b/crates/common/src/config/telemetry.rs @@ -8,10 +8,7 @@ use std::{collections::HashMap, str::FromStr, sync::Arc, time::Duration}; use ahash::{AHashMap, AHashSet}; use base64::{engine::general_purpose::STANDARD, Engine}; -use hyper::{ - header::{HeaderName, HeaderValue, AUTHORIZATION, CONTENT_TYPE}, - HeaderMap, -}; +use hyper::{header::CONTENT_TYPE, HeaderMap}; use opentelemetry::{InstrumentationLibrary, KeyValue}; use opentelemetry_otlp::WithExportConfig; use opentelemetry_sdk::{ @@ -27,6 +24,8 @@ use store::Stores; use trc::{ipc::subscriber::Interests, EventType, Level, TelemetryEvent}; use utils::config::{utils::ParseValue, Config}; +use super::parse_http_headers; + #[derive(Debug)] pub struct TelemetrySubscriber { pub id: String, @@ -753,45 +752,8 @@ fn parse_webhook( id: &str, global_interests: &mut Interests, ) -> Option { - let mut headers = HeaderMap::new(); - - for (header, value) in config - .values(("webhook", id, "headers")) - .map(|(_, v)| { - if let Some((k, v)) = v.split_once(':') { - Ok(( - HeaderName::from_str(k.trim()).map_err(|err| { - format!("Invalid header found in property \"webhook.{id}.headers\": {err}",) - })?, - HeaderValue::from_str(v.trim()).map_err(|err| { - format!("Invalid header found in property \"webhook.{id}.headers\": {err}",) - })?, - )) - } else { - Err(format!( - "Invalid header found in property \"webhook.{id}.headers\": {v}", - )) - } - }) - .collect::, String>>() - .map_err(|e| config.new_parse_error(("webhook", id, "headers"), e)) - .unwrap_or_default() - { - headers.insert(header, value); - } - + let mut headers = parse_http_headers(config, ("webhook", id)); headers.insert(CONTENT_TYPE, "application/json".parse().unwrap()); - if let (Some(name), Some(secret)) = ( - config.value(("webhook", id, "auth.username")), - config.value(("webhook", id, "auth.secret")), - ) { - headers.insert( - AUTHORIZATION, - format!("Basic {}", STANDARD.encode(format!("{}:{}", name, secret))) - .parse() - .unwrap(), - ); - } // Build tracer let mut tracer = TelemetrySubscriber { diff --git a/crates/common/src/enterprise/config.rs b/crates/common/src/enterprise/config.rs index b076ab65..f79b0697 100644 --- a/crates/common/src/enterprise/config.rs +++ b/crates/common/src/enterprise/config.rs @@ -10,6 +10,7 @@ use std::time::Duration; +use ahash::AHashMap; use directory::{backend::internal::manage::ManageDirectory, Type}; use store::{Store, Stores}; use trc::{EventType, MetricType, TOTAL_EVENT_COUNT}; @@ -22,8 +23,8 @@ use utils::config::{ use crate::expr::{tokenizer::TokenMap, Expression}; use super::{ - license::LicenseValidator, AlertContent, AlertContentToken, AlertMethod, Enterprise, - MetricAlert, MetricStore, TraceStore, Undelete, + license::LicenseValidator, llm::AiApiConfig, AlertContent, AlertContentToken, AlertMethod, + Enterprise, MetricAlert, MetricStore, TraceStore, Undelete, }; impl Enterprise { @@ -111,6 +112,18 @@ impl Enterprise { None }; + // Parse AI APIs + let mut ai_apis = AHashMap::new(); + for id in config + .sub_keys("enterprise.ai", ".url") + .map(|s| s.to_string()) + .collect::>() + { + if let Some(api) = AiApiConfig::parse(config, &id) { + ai_apis.insert(id, api); + } + } + Some(Enterprise { license, undelete: config @@ -121,6 +134,7 @@ impl Enterprise { trace_store, metrics_store, metrics_alerts: parse_metric_alerts(config), + ai_apis, }) } } diff --git a/crates/common/src/enterprise/llm.rs b/crates/common/src/enterprise/llm.rs new file mode 100644 index 00000000..befd665d --- /dev/null +++ b/crates/common/src/enterprise/llm.rs @@ -0,0 +1,234 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: LicenseRef-SEL + * + * This file is subject to the Stalwart Enterprise License Agreement (SEL) and + * is NOT open source software. + * + */ + +use std::time::Duration; + +use hyper::{header::CONTENT_TYPE, HeaderMap}; +use serde::{Deserialize, Serialize}; +use utils::config::Config; + +use crate::config::parse_http_headers; + +#[derive(Clone)] +pub struct AiApiConfig { + pub id: String, + pub api_type: ApiType, + pub url: String, + pub model: String, + pub timeout: Duration, + pub headers: HeaderMap, + pub tls_allow_invalid_certs: bool, + pub default_temperature: f64, +} + +#[derive(Clone, Copy)] +pub enum ApiType { + ChatCompletion, + TextCompletion, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ChatCompletionRequest { + pub model: String, + pub messages: Vec, + pub temperature: f64, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct Message { + pub role: String, + pub content: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ChatCompletionResponse { + pub created: i64, + pub object: String, + pub id: String, + pub model: String, + pub choices: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ChatCompletionChoice { + pub index: i32, + pub finish_reason: String, + pub message: Message, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct TextCompletionRequest { + pub model: String, + pub prompt: String, + pub temperature: f64, +} + +#[derive(Deserialize, Debug)] +pub struct TextCompletionResponse { + pub created: i64, + pub object: String, + pub id: String, + pub model: String, + pub choices: Vec, +} + +#[derive(Deserialize, Debug)] +pub struct TextCompletionChoice { + pub index: i32, + pub finish_reason: String, + pub text: String, +} + +impl AiApiConfig { + pub async fn send_request( + &self, + prompt: impl Into, + temperature: Option, + ) -> trc::Result { + self.post_api(prompt, temperature).await.map_err(|err| { + trc::Error::new(trc::EventType::Ai(trc::AiEvent::ApiError)) + .id(self.id.clone()) + .details("OpenAPI request failed") + .reason(err) + }) + } + + async fn post_api( + &self, + prompt: impl Into, + temperature: Option, + ) -> Result { + // Serialize body + let body = match self.api_type { + ApiType::ChatCompletion => serde_json::to_string(&ChatCompletionRequest { + model: self.model.to_string(), + messages: vec![Message { + role: "user".to_string(), + content: prompt.into(), + }], + temperature: temperature.unwrap_or(self.default_temperature), + }) + .map_err(|err| format!("Failed to serialize request: {}", err))?, + ApiType::TextCompletion => serde_json::to_string(&TextCompletionRequest { + model: self.model.to_string(), + prompt: prompt.into(), + temperature: temperature.unwrap_or(self.default_temperature), + }) + .map_err(|err| format!("Failed to serialize request: {}", err))?, + }; + + // Send request + let response = reqwest::Client::builder() + .timeout(self.timeout) + .danger_accept_invalid_certs(self.tls_allow_invalid_certs) + .build() + .map_err(|err| format!("Failed to create HTTP client: {}", err))? + .post(&self.url) + .headers(self.headers.clone()) + .body(body) + .send() + .await + .map_err(|err| format!("API request to {} failed: {err}", self.url))?; + + if response.status().is_success() { + let bytes = response.bytes().await.map_err(|err| { + format!("Failed to read response body from {}: {}", self.url, err) + })?; + + match self.api_type { + ApiType::ChatCompletion => { + let response = serde_json::from_slice::(&bytes) + .map_err(|err| { + format!( + "Failed to chat completion parse response from {}: {}", + self.url, err + ) + })?; + response + .choices + .into_iter() + .next() + .map(|choice| choice.message.content) + .filter(|text| !text.is_empty()) + .ok_or_else(|| { + format!( + "Chat completion response from {} did not contain any choices: {}", + self.url, + std::str::from_utf8(&bytes).unwrap_or_default() + ) + }) + } + ApiType::TextCompletion => { + let response = serde_json::from_slice::(&bytes) + .map_err(|err| { + format!( + "Failed to parse text completion response from {}: {}", + self.url, err + ) + })?; + response + .choices + .into_iter() + .next() + .map(|choice| choice.text) + .filter(|text| !text.is_empty()) + .ok_or_else(|| { + format!( + "Text completion response from {} did not contain any choices: {}", + self.url, + std::str::from_utf8(&bytes).unwrap_or_default() + ) + }) + } + } + } else { + Err(format!( + "OpenAPI request to {} failed with code {}: {}", + self.url, + response.status().as_u16(), + response.status().canonical_reason().unwrap_or("Unknown") + )) + } + } + + pub fn parse(config: &mut Config, id: &str) -> Option { + let url = config.value(("enterprise.ai", id, "endpoint"))?.to_string(); + let api_type = match config.value(("enterprise.ai", id, "type"))? { + "chat" => ApiType::ChatCompletion, + "text" => ApiType::TextCompletion, + _ => { + config.new_build_error(("enterprise.ai", id, "type"), "Invalid API type"); + return None; + } + }; + + let mut headers = parse_http_headers(config, ("enterprise.ai", id)); + headers.insert(CONTENT_TYPE, "application/json".parse().unwrap()); + + Some(AiApiConfig { + id: id.to_string(), + api_type, + url, + headers, + model: config + .value_require(("enterprise.ai", id, "model"))? + .to_string(), + timeout: config + .property_or_default(("enterprise.ai", id, "timeout"), "2m") + .unwrap_or_else(|| Duration::from_secs(120)), + tls_allow_invalid_certs: config + .property_or_default(("enterprise.ai", id, "allow-invalid-certs"), "false") + .unwrap_or_default(), + default_temperature: config + .property_or_default(("enterprise.ai", id, "default-temperature"), "0.7") + .unwrap_or(0.7), + }) + } +} diff --git a/crates/common/src/enterprise/mod.rs b/crates/common/src/enterprise/mod.rs index 6ffe400d..e9325c18 100644 --- a/crates/common/src/enterprise/mod.rs +++ b/crates/common/src/enterprise/mod.rs @@ -11,15 +11,18 @@ pub mod alerts; pub mod config; pub mod license; +pub mod llm; pub mod undelete; use std::time::Duration; +use ahash::AHashMap; use directory::{ backend::internal::{lookup::DirectoryStore, PrincipalField}, QueryBy, Type, }; use license::LicenseKey; +use llm::AiApiConfig; use mail_parser::DateTime; use store::Store; use trc::{AddContext, EventType, MetricType}; @@ -35,6 +38,7 @@ pub struct Enterprise { pub trace_store: Option, pub metrics_store: Option, pub metrics_alerts: Vec, + pub ai_apis: AHashMap, } #[derive(Clone)] diff --git a/crates/common/src/expr/functions/mod.rs b/crates/common/src/expr/functions/mod.rs index 3d6f0c28..11b17ec0 100644 --- a/crates/common/src/expr/functions/mod.rs +++ b/crates/common/src/expr/functions/mod.rs @@ -76,6 +76,7 @@ pub(crate) const FUNCTIONS: &[(&str, fn(Vec) -> Variable, u32)] = &[ ("rsplit", text::fn_rsplit, 2), ("split_once", text::fn_split_once, 2), ("rsplit_once", text::fn_rsplit_once, 2), + ("split_n", text::fn_split_n, 3), ("split_words", text::fn_split_words, 1), ]; diff --git a/crates/common/src/expr/functions/text.rs b/crates/common/src/expr/functions/text.rs index 7bb6cf00..b32e2e62 100644 --- a/crates/common/src/expr/functions/text.rs +++ b/crates/common/src/expr/functions/text.rs @@ -239,6 +239,36 @@ pub(crate) fn fn_rsplit(v: Vec) -> Variable { } } +pub(crate) fn fn_split_n(v: Vec) -> Variable { + let mut v = v.into_iter(); + let value = v.next().unwrap().into_string(); + let arg = v.next().unwrap().into_string(); + let num = v.next().unwrap().to_integer().unwrap_or_default() as usize; + + fn split_n<'x, 'y>(s: &'x str, arg: &'y str, num: usize, mut f: impl FnMut(&'x str)) { + let mut s = s; + for _ in 0..num { + if let Some((a, b)) = s.split_once(arg) { + f(a); + s = b; + } else { + break; + } + } + f(s); + } + + let mut result = Vec::new(); + match value { + Cow::Borrowed(s) => split_n(s, arg.as_ref(), num, |s| result.push(Variable::from(s))), + Cow::Owned(s) => split_n(&s, arg.as_ref(), num, |s| { + result.push(Variable::from(s.to_string())) + }), + } + + result.into() +} + pub(crate) fn fn_split_once(v: Vec) -> Variable { let mut v = v.into_iter(); let value = v.next().unwrap().into_string(); diff --git a/crates/common/src/scripts/functions/misc.rs b/crates/common/src/scripts/functions/misc.rs index 2d4c65f5..00c3b8c2 100644 --- a/crates/common/src/scripts/functions/misc.rs +++ b/crates/common/src/scripts/functions/misc.rs @@ -94,7 +94,7 @@ pub fn fn_hash<'x>(_: &'x Context<'x>, v: Vec) -> Variable { }) } -pub fn fn_is_var_names<'x>(ctx: &'x Context<'x>, _: Vec) -> Variable { +pub fn fn_get_var_names<'x>(ctx: &'x Context<'x>, _: Vec) -> Variable { Variable::Array( ctx.global_variable_names() .map(|v| Variable::from(v.to_uppercase())) diff --git a/crates/common/src/scripts/functions/mod.rs b/crates/common/src/scripts/functions/mod.rs index 31aca019..c08f9f8f 100644 --- a/crates/common/src/scripts/functions/mod.rs +++ b/crates/common/src/scripts/functions/mod.rs @@ -20,7 +20,7 @@ use self::{ array::*, email::*, header::*, html::*, image::*, misc::*, text::*, unicode::*, url::*, }; -pub fn register_functions() -> FunctionMap { +pub fn register_functions_trusted() -> FunctionMap { FunctionMap::new() .with_function("trim", fn_trim) .with_function("trim_start", fn_trim_start) @@ -80,6 +80,7 @@ pub fn register_functions() -> FunctionMap { .with_function_args("rsplit", fn_rsplit, 2) .with_function_args("split_once", fn_split_once, 2) .with_function_args("rsplit_once", fn_rsplit_once, 2) + .with_function_args("split_n", fn_split_n, 3) .with_function_args("strip_prefix", fn_strip_prefix, 2) .with_function_args("strip_suffix", fn_strip_suffix, 2) .with_function_args("is_intersect", fn_is_intersect, 2) @@ -87,11 +88,58 @@ pub fn register_functions() -> FunctionMap { .with_function_no_args("is_encoding_problem", fn_is_encoding_problem) .with_function_no_args("is_attachment", fn_is_attachment) .with_function_no_args("is_body", fn_is_body) - .with_function_no_args("var_names", fn_is_var_names) + .with_function_no_args("var_names", fn_get_var_names) .with_function_no_args("attachment_name", fn_attachment_name) .with_function_no_args("mime_part_len", fn_mime_part_len) } +pub fn register_functions_untrusted() -> FunctionMap { + FunctionMap::new() + .with_function("trim", fn_trim) + .with_function("trim_start", fn_trim_start) + .with_function("trim_end", fn_trim_end) + .with_function("len", fn_len) + .with_function("count", fn_count) + .with_function("is_empty", fn_is_empty) + .with_function("is_number", fn_is_number) + .with_function("is_ascii", fn_is_ascii) + .with_function("to_lowercase", fn_to_lowercase) + .with_function("to_uppercase", fn_to_uppercase) + .with_function("is_email", fn_is_email) + .with_function("thread_name", fn_thread_name) + .with_function("html_to_text", fn_html_to_text) + .with_function("is_uppercase", fn_is_uppercase) + .with_function("is_lowercase", fn_is_lowercase) + .with_function("has_digits", fn_has_digits) + .with_function("count_spaces", fn_count_spaces) + .with_function("count_uppercase", fn_count_uppercase) + .with_function("count_lowercase", fn_count_lowercase) + .with_function("count_chars", fn_count_chars) + .with_function("dedup", fn_dedup) + .with_function("lines", fn_lines) + .with_function("is_ip_addr", fn_is_ip_addr) + .with_function("is_ipv4_addr", fn_is_ipv4_addr) + .with_function("is_ipv6_addr", fn_is_ipv6_addr) + .with_function("winnow", fn_winnow) + .with_function_args("sort", fn_sort, 2) + .with_function_args("email_part", fn_email_part, 2) + .with_function_args("eq_ignore_case", fn_eq_ignore_case, 2) + .with_function_args("contains", fn_contains, 2) + .with_function_args("contains_ignore_case", fn_contains_ignore_case, 2) + .with_function_args("starts_with", fn_starts_with, 2) + .with_function_args("ends_with", fn_ends_with, 2) + .with_function_args("uri_part", fn_uri_part, 2) + .with_function_args("substring", fn_substring, 3) + .with_function_args("split", fn_split, 2) + .with_function_args("rsplit", fn_rsplit, 2) + .with_function_args("split_once", fn_split_once, 2) + .with_function_args("rsplit_once", fn_rsplit_once, 2) + .with_function_args("split_n", fn_split_n, 3) + .with_function_args("strip_prefix", fn_strip_prefix, 2) + .with_function_args("strip_suffix", fn_strip_suffix, 2) + .with_function_args("is_intersect", fn_is_intersect, 2) +} + pub trait ApplyString<'x> { fn transform(&self, f: impl Fn(&'_ str) -> Variable) -> Variable; } diff --git a/crates/common/src/scripts/functions/text.rs b/crates/common/src/scripts/functions/text.rs index 33d9a25f..32a15507 100644 --- a/crates/common/src/scripts/functions/text.rs +++ b/crates/common/src/scripts/functions/text.rs @@ -191,6 +191,25 @@ pub fn fn_rsplit<'x>(_: &'x Context<'x>, v: Vec) -> Variable { .into() } +pub fn fn_split_n<'x>(_: &'x Context<'x>, v: Vec) -> Variable { + let value = v[0].to_string(); + let arg = v[1].to_string(); + let num = v[2].to_integer() as usize; + let mut result = Vec::new(); + + let mut s = value.as_ref(); + for _ in 0..num { + if let Some((a, b)) = s.split_once(arg.as_ref()) { + result.push(Variable::from(a.to_string())); + s = b; + } else { + break; + } + } + result.push(Variable::from(s.to_string())); + result.into() +} + pub fn fn_split_once<'x>(_: &'x Context<'x>, v: Vec) -> Variable { v[0].to_string() .split_once(v[1].to_string().as_ref()) diff --git a/crates/common/src/scripts/plugins/llm_prompt.rs b/crates/common/src/scripts/plugins/llm_prompt.rs new file mode 100644 index 00000000..4be76ca0 --- /dev/null +++ b/crates/common/src/scripts/plugins/llm_prompt.rs @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Instant; + +use directory::Permission; +use sieve::{runtime::Variable, FunctionMap}; +use trc::{AiEvent, SecurityEvent}; + +use super::PluginContext; + +pub fn register(plugin_id: u32, fnc_map: &mut FunctionMap) { + fnc_map.set_external_function("llm_prompt", plugin_id, 2); +} + +pub async fn exec(ctx: PluginContext<'_>) -> trc::Result { + // SPDX-SnippetBegin + // SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + // SPDX-License-Identifier: LicenseRef-SEL + + #[cfg(feature = "enterprise")] + if let (Variable::String(name), Variable::String(prompt)) = + (&ctx.arguments[0], &ctx.arguments[1]) + { + #[cfg(feature = "test_mode")] + if name.as_ref() == "echo-test" { + return Ok(prompt.to_string().into()); + } + + if let Some(ai_api) = ctx.server.core.enterprise.as_ref().and_then(|e| { + if ctx.access_token.map_or(true, |token| { + if token.has_permission(Permission::AiModelInteract) { + true + } else { + trc::event!( + Security(SecurityEvent::Unauthorized), + AccountId = token.primary_id(), + Details = Permission::AiModelInteract.name(), + SpanId = ctx.session_id, + ); + false + } + }) { + if e.ai_apis.len() == 1 && name.is_empty() { + e.ai_apis.values().next() + } else { + e.ai_apis.get(name.as_ref()) + } + } else { + None + } + }) { + let time = Instant::now(); + match ai_api.send_request(prompt.as_ref(), None).await { + Ok(response) => { + trc::event!( + Ai(AiEvent::LlmResponse), + Id = ai_api.id.clone(), + Value = prompt.to_string(), + Details = response.clone(), + Elapsed = time.elapsed(), + SpanId = ctx.session_id, + ); + + return Ok(response.into()); + } + Err(err) => { + trc::error!(err.span_id(ctx.session_id)); + } + } + } + } + + // SPDX-SnippetEnd + + Ok(false.into()) +} diff --git a/crates/common/src/scripts/plugins/mod.rs b/crates/common/src/scripts/plugins/mod.rs index ba681eee..a1871395 100644 --- a/crates/common/src/scripts/plugins/mod.rs +++ b/crates/common/src/scripts/plugins/mod.rs @@ -9,6 +9,7 @@ pub mod dns; pub mod exec; pub mod headers; pub mod http; +pub mod llm_prompt; pub mod lookup; pub mod pyzor; pub mod query; @@ -17,7 +18,7 @@ pub mod text; use mail_parser::Message; use sieve::{runtime::Variable, FunctionMap, Input}; -use crate::{Core, Server}; +use crate::{auth::AccessToken, Core, Server}; use super::ScriptModification; @@ -25,13 +26,14 @@ type RegisterPluginFnc = fn(u32, &mut FunctionMap) -> (); pub struct PluginContext<'x> { pub session_id: u64, + pub access_token: Option<&'x AccessToken>, pub server: &'x Server, pub message: &'x Message<'x>, pub modifications: &'x mut Vec, pub arguments: Vec, } -const PLUGINS_REGISTER: [RegisterPluginFnc; 18] = [ +const PLUGINS_REGISTER: [RegisterPluginFnc; 19] = [ query::register, exec::register, lookup::register, @@ -50,14 +52,16 @@ const PLUGINS_REGISTER: [RegisterPluginFnc; 18] = [ headers::register, text::register_tokenize, text::register_domain_part, + llm_prompt::register, ]; pub trait RegisterSievePlugins { - fn register_plugins(self) -> Self; + fn register_plugins_trusted(self) -> Self; + fn register_plugins_untrusted(self) -> Self; } impl RegisterSievePlugins for FunctionMap { - fn register_plugins(mut self) -> Self { + fn register_plugins_trusted(mut self) -> Self { #[cfg(feature = "test_mode")] { self.set_external_function("print", PLUGINS_REGISTER.len() as u32, 1) @@ -68,6 +72,11 @@ impl RegisterSievePlugins for FunctionMap { } self } + + fn register_plugins_untrusted(mut self) -> Self { + llm_prompt::register(18, &mut self); + self + } } impl Core { @@ -97,6 +106,7 @@ impl Core { 15 => headers::exec(ctx), 16 => text::exec_tokenize(ctx), 17 => text::exec_domain_part(ctx), + 18 => llm_prompt::exec(ctx).await, _ => unreachable!(), }; diff --git a/crates/directory/src/core/mod.rs b/crates/directory/src/core/mod.rs index 4f420693..89428ded 100644 --- a/crates/directory/src/core/mod.rs +++ b/crates/directory/src/core/mod.rs @@ -197,6 +197,7 @@ impl Permission { Permission::OauthClientCreate => "Create new OAuth clients", Permission::OauthClientUpdate => "Modify OAuth clients", Permission::OauthClientDelete => "Remove OAuth clients", + Permission::AiModelInteract => "Interact with AI models", } } } diff --git a/crates/directory/src/lib.rs b/crates/directory/src/lib.rs index 48880ac0..6bd13cdb 100644 --- a/crates/directory/src/lib.rs +++ b/crates/directory/src/lib.rs @@ -262,6 +262,8 @@ pub enum Permission { // OAuth client registration OauthClientRegistration, OauthClientOverride, + + AiModelInteract, // WARNING: add new ids at the end (TODO: use static ids) } diff --git a/crates/jmap-proto/Cargo.toml b/crates/jmap-proto/Cargo.toml index 075e153b..bdc91799 100644 --- a/crates/jmap-proto/Cargo.toml +++ b/crates/jmap-proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jmap_proto" -version = "0.1.0" +version = "0.10.2" edition = "2021" resolver = "2" diff --git a/crates/jmap/src/api/management/sieve.rs b/crates/jmap/src/api/management/sieve.rs index 85159378..ccc1b337 100644 --- a/crates/jmap/src/api/management/sieve.rs +++ b/crates/jmap/src/api/management/sieve.rs @@ -111,7 +111,10 @@ impl SieveHandler for Server { } // Run script - let result = match self.run_script(script_id, script, params, 0).await { + let result = match self + .run_script(script_id, script, params.with_access_token(access_token)) + .await + { ScriptResult::Accept { modifications } => Response::Accept { modifications }, ScriptResult::Replace { message, diff --git a/crates/jmap/src/sieve/ingest.rs b/crates/jmap/src/sieve/ingest.rs index 08ba6051..c05917db 100644 --- a/crates/jmap/src/sieve/ingest.rs +++ b/crates/jmap/src/sieve/ingest.rs @@ -6,7 +6,9 @@ use std::borrow::Cow; -use common::{auth::AccessToken, listener::stream::NullIo, Server}; +use common::{ + auth::AccessToken, listener::stream::NullIo, scripts::plugins::PluginContext, Server, +}; use directory::{backend::internal::PrincipalField, QueryBy}; use jmap_proto::types::{collection::Collection, id::Id, keyword::Keyword, property::Property}; use mail_parser::MessageParser; @@ -398,12 +400,27 @@ impl SieveScriptIngest for Server { } } Event::ListContains { .. } - | Event::Function { .. } | Event::Notify { .. } | Event::SetEnvelope { .. } => { // Not allowed input = false.into(); } + Event::Function { id, arguments } => { + input = self + .core + .run_plugin( + id, + PluginContext { + session_id, + server: self, + message: instance.message(), + modifications: &mut Vec::new(), + access_token: access_token.into(), + arguments, + }, + ) + .await; + } Event::CreatedMessage { message, .. } => { messages.push(SieveMessage { raw_message: message.into(), diff --git a/crates/smtp/src/scripts/event_loop.rs b/crates/smtp/src/scripts/event_loop.rs index da6885cd..ebc7fab3 100644 --- a/crates/smtp/src/scripts/event_loop.rs +++ b/crates/smtp/src/scripts/event_loop.rs @@ -31,7 +31,6 @@ pub trait RunScript: Sync + Send { script_id: String, script: Arc, params: ScriptParameters<'_>, - session_id: u64, ) -> impl Future + Send; } @@ -41,7 +40,6 @@ impl RunScript for Server { script_id: String, script: Arc, params: ScriptParameters<'_>, - session_id: u64, ) -> ScriptResult { // Create filter instance let time = Instant::now(); @@ -56,6 +54,7 @@ impl RunScript for Server { .with_user_full_name(¶ms.from_name); let mut input = Input::script("__script", script); let mut messages: Vec> = Vec::new(); + let session_id = params.session_id; let mut reject_reason = None; let mut modifications = vec![]; @@ -124,6 +123,7 @@ impl RunScript for Server { server: self, message: instance.message(), modifications: &mut modifications, + access_token: params.access_token, arguments, }, ) diff --git a/crates/smtp/src/scripts/exec.rs b/crates/smtp/src/scripts/exec.rs index 7ddc90fe..8264abbc 100644 --- a/crates/smtp/src/scripts/exec.rs +++ b/crates/smtp/src/scripts/exec.rs @@ -132,9 +132,9 @@ impl Session { script_id, script, params + .with_session_id(self.data.session_id) .with_envelope(&self.server, self, self.data.session_id) .await, - self.data.session_id, ) .await } diff --git a/crates/smtp/src/scripts/mod.rs b/crates/smtp/src/scripts/mod.rs index cd162731..901bf6b3 100644 --- a/crates/smtp/src/scripts/mod.rs +++ b/crates/smtp/src/scripts/mod.rs @@ -7,7 +7,9 @@ use std::borrow::Cow; use ahash::AHashMap; -use common::{expr::functions::ResolveVariable, scripts::ScriptModification, Server}; +use common::{ + auth::AccessToken, expr::functions::ResolveVariable, scripts::ScriptModification, Server, +}; use sieve::{runtime::Variable, Envelope}; pub mod envelope; @@ -38,6 +40,8 @@ pub struct ScriptParameters<'x> { sign: Vec, #[cfg(feature = "test_mode")] expected_variables: Option>, + access_token: Option<&'x AccessToken>, + session_id: u64, } impl<'x> ScriptParameters<'x> { @@ -53,6 +57,8 @@ impl<'x> ScriptParameters<'x> { from_name: Default::default(), return_path: Default::default(), sign: Default::default(), + access_token: None, + session_id: Default::default(), } } @@ -108,6 +114,16 @@ impl<'x> ScriptParameters<'x> { self } + pub fn with_access_token(mut self, access_token: &'x AccessToken) -> Self { + self.access_token = Some(access_token); + self + } + + pub fn with_session_id(mut self, session_id: u64) -> Self { + self.session_id = session_id; + self + } + #[cfg(feature = "test_mode")] pub fn with_expected_variables( mut self, diff --git a/crates/trc/src/event/description.rs b/crates/trc/src/event/description.rs index c475abae..07fca954 100644 --- a/crates/trc/src/event/description.rs +++ b/crates/trc/src/event/description.rs @@ -51,6 +51,7 @@ impl EventType { EventType::Telemetry(event) => event.description(), EventType::MessageIngest(event) => event.description(), EventType::Security(event) => event.description(), + EventType::Ai(event) => event.description(), } } @@ -98,6 +99,7 @@ impl EventType { EventType::Telemetry(event) => event.explain(), EventType::MessageIngest(event) => event.explain(), EventType::Security(event) => event.explain(), + EventType::Ai(event) => event.explain(), } } } @@ -1806,3 +1808,19 @@ impl SecurityEvent { } } } + +impl AiEvent { + pub fn description(&self) -> &'static str { + match self { + AiEvent::LlmResponse => "LLM response", + AiEvent::ApiError => "AI API error", + } + } + + pub fn explain(&self) -> &'static str { + match self { + AiEvent::LlmResponse => "An LLM response has been received", + AiEvent::ApiError => "An AI API error occurred", + } + } +} diff --git a/crates/trc/src/event/level.rs b/crates/trc/src/event/level.rs index 097fb1e5..23dbef61 100644 --- a/crates/trc/src/event/level.rs +++ b/crates/trc/src/event/level.rs @@ -533,6 +533,10 @@ impl EventType { MessageIngestEvent::Error => Level::Error, }, EventType::Security(_) => Level::Info, + EventType::Ai(event) => match event { + AiEvent::LlmResponse => Level::Trace, + AiEvent::ApiError => Level::Warn, + }, } } } diff --git a/crates/trc/src/lib.rs b/crates/trc/src/lib.rs index 28271d91..1bf7b0af 100644 --- a/crates/trc/src/lib.rs +++ b/crates/trc/src/lib.rs @@ -183,6 +183,7 @@ pub enum EventType { OutgoingReport(OutgoingReportEvent), Telemetry(TelemetryEvent), Security(SecurityEvent), + Ai(AiEvent), } #[event_type] @@ -939,6 +940,12 @@ pub enum ResourceEvent { WebadminUnpacked, } +#[event_type] +pub enum AiEvent { + LlmResponse, + ApiError, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum MetricType { ServerMemory, diff --git a/crates/trc/src/serializers/binary.rs b/crates/trc/src/serializers/binary.rs index 3f37f895..12182bf9 100644 --- a/crates/trc/src/serializers/binary.rs +++ b/crates/trc/src/serializers/binary.rs @@ -861,6 +861,8 @@ impl EventType { EventType::Limit(LimitEvent::TenantQuota) => 553, EventType::Auth(AuthEvent::TokenExpired) => 554, EventType::Auth(AuthEvent::ClientRegistration) => 555, + EventType::Ai(AiEvent::LlmResponse) => 556, + EventType::Ai(AiEvent::ApiError) => 557, } } @@ -1462,6 +1464,8 @@ impl EventType { 553 => Some(EventType::Limit(LimitEvent::TenantQuota)), 554 => Some(EventType::Auth(AuthEvent::TokenExpired)), 555 => Some(EventType::Auth(AuthEvent::ClientRegistration)), + 556 => Some(EventType::Ai(AiEvent::LlmResponse)), + 557 => Some(EventType::Ai(AiEvent::ApiError)), _ => None, } } diff --git a/resources/config/build.py b/resources/config/build.py index a1d35807..59bf8fbb 100644 --- a/resources/config/build.py +++ b/resources/config/build.py @@ -31,6 +31,7 @@ scripts = { "url.sieve", "rbl.sieve", "pyzor.sieve", + "llm.sieve", "composites.sieve", "scores.sieve", "reputation.sieve", @@ -80,7 +81,7 @@ def read_file(file): return f.read() + "\n" def build_spam_filters(scripts): - spam_filter = "[version]\nspam-filter = \"1.1\"\n\n" + spam_filter = "[version]\nspam-filter = \"1.2\"\n\n" for script_name, file_list in scripts.items(): script_content = read_and_concatenate(file_list).replace("'''", "\\'\\'\\'") script_description = script_names[script_name] diff --git a/resources/config/spamfilter/maps/scores.map b/resources/config/spamfilter/maps/scores.map index 9dad8564..8b5c55b8 100644 --- a/resources/config/spamfilter/maps/scores.map +++ b/resources/config/spamfilter/maps/scores.map @@ -361,4 +361,16 @@ spam-scores = {"ABUSE_SURBL" = "5.0", "SHORT_PART_BAD_HEADERS" = "7.0", "MISSING_ESSENTIAL_HEADERS" = "7.0", "SINGLE_SHORT_PART" = "0.0", -"COMPLETELY_EMPTY" = "7.0"} +"COMPLETELY_EMPTY" = "7.0", +"LLM_UNSOLICITED_HIGH" = "3.0", +"LLM_UNSOLICITED_MEDIUM" = "2.0", +"LLM_UNSOLICITED_LOW" = "0.5", +"LLM_COMMERCIAL_HIGH" = "3.0", +"LLM_COMMERCIAL_MEDIUM" = "2.0", +"LLM_COMMERCIAL_LOW" = "0.5", +"LLM_HARMFUL_HIGH" = "3.0", +"LLM_HARMFUL_MEDIUM" = "2.0", +"LLM_HARMFUL_LOW" = "0.5", +"LLM_LEGITIMATE_HIGH" = "-3.0", +"LLM_LEGITIMATE_MEDIUM" = "-2.0", +"LLM_LEGITIMATE_LOW" = "-0.5"} diff --git a/resources/config/spamfilter/maps/spam_config.map b/resources/config/spamfilter/maps/spam_config.map index c8a167b7..02b81316 100644 --- a/resources/config/spamfilter/maps/spam_config.map +++ b/resources/config/spamfilter/maps/spam_config.map @@ -10,5 +10,30 @@ spam-config = { "threshold-discard" = "0.0", "threshold-reject" = "0.0", "directory" = "", -"lookup" = "" +"lookup" = "", +"llm-model" = "", +"llm-prompt" = "You are an AI assistant specialized in analyzing email content to detect unsolicited, commercial, or harmful messages. Your task is to examine the provided email, including its subject line, and determine if it falls into any of these categories. Please follow these steps: + +- Carefully read the entire email content, including the subject line. +- Look for indicators of unsolicited messages, such as: + * Lack of prior relationship or consent + * Mass-mailing characteristics + * Vague or misleading sender information +- Identify commercial content by checking for: + * Promotional language + * Product or service offerings + * Call-to-action for purchases +- Detect potentially harmful content by searching for: + * Phishing attempts (requests for personal information, suspicious links) + * Malware indicators (suspicious attachments, urgent calls to action) + * Scams or fraudulent schemes +- Analyze the overall tone, intent, and legitimacy of the email. +- Determine the most appropriate single category for the email: Unsolicited, Commercial, Harmful, or Legitimate. +- Assess your confidence level in this determination: High, Medium, or Low. +- Provide a brief explanation for your determination. +- Format your response as follows, separated by commas: Category,Confidence,Explanation + * Example: Unsolicited,High,The email contains mass-mailing characteristics without any prior relationship context. + +Here's the email to analyze, please provide your analysis based on the above instructions, ensuring your response is in the specified comma-separated format:", +"add-llm-result" = true } diff --git a/resources/config/spamfilter/scripts/config.sieve b/resources/config/spamfilter/scripts/config.sieve index 33d69816..ee220af3 100644 --- a/resources/config/spamfilter/scripts/config.sieve +++ b/resources/config/spamfilter/scripts/config.sieve @@ -33,3 +33,12 @@ let "DOMAIN_DIRECTORY" "key_get('spam-config', 'directory')"; # Store to use for Bayes tokens and ids (leave empty for default) let "SPAM_DB" "key_get('spam-config', 'lookup')"; + +# LLM model to use for spam classification +let "LLM_MODEL" "key_get('spam-config', 'llm-model')"; + +# LLM prompt to use for spam classification +let "LLM_PROMPT_TEXT" "key_get('spam-config', 'llm-prompt')"; + +# Whether to add an X-Spam-Llm-Result header +let "ADD_HEADER_LLM" "key_get('spam-config', 'add-llm-result')"; diff --git a/resources/config/spamfilter/scripts/llm.sieve b/resources/config/spamfilter/scripts/llm.sieve new file mode 100644 index 00000000..f0cd289d --- /dev/null +++ b/resources/config/spamfilter/scripts/llm.sieve @@ -0,0 +1,41 @@ +if eval "LLM_MODEL && LLM_PROMPT_TEXT" { + let "llm_result" "trim(split_n(llm_prompt(LLM_MODEL, LLM_PROMPT_TEXT + '\n\nSubject: ' + subject_clean + '\n\n' + text_body), ',', 3))"; + + if eval "eq_ignore_case(llm_result[0], 'Unsolicited')" { + if eval "eq_ignore_case(llm_result[1], 'High')" { + let "t.LLM_UNSOLICITED_HIGH" "1"; + } elsif eval "eq_ignore_case(llm_result[1], 'Medium')" { + let "t.LLM_UNSOLICITED_MEDIUM" "1"; + } else { + let "t.LLM_UNSOLICITED_LOW" "1"; + } + } elsif eval "eq_ignore_case(llm_result[0], 'Commercial')" { + if eval "eq_ignore_case(llm_result[1], 'High')" { + let "t.LLM_COMMERCIAL_HIGH" "1"; + } elsif eval "eq_ignore_case(llm_result[1], 'Medium')" { + let "t.LLM_COMMERCIAL_MEDIUM" "1"; + } else { + let "t.LLM_COMMERCIAL_LOW" "1"; + } + } elsif eval "eq_ignore_case(llm_result[0], 'Harmful')" { + if eval "eq_ignore_case(llm_result[1], 'High')" { + let "t.LLM_HARMFUL_HIGH" "1"; + } elsif eval "eq_ignore_case(llm_result[1], 'Medium')" { + let "t.LLM_HARMFUL_MEDIUM" "1"; + } else { + let "t.LLM_HARMFUL_LOW" "1"; + } + } elsif eval "eq_ignore_case(llm_result[0], 'Legitimate')" { + if eval "eq_ignore_case(llm_result[1], 'High')" { + let "t.LLM_LEGITIMATE_HIGH" "1"; + } elsif eval "eq_ignore_case(llm_result[1], 'Medium')" { + let "t.LLM_LEGITIMATE_MEDIUM" "1"; + } else { + let "t.LLM_LEGITIMATE_LOW" "1"; + } + } + + if eval "ADD_HEADER_LLM && count(llm_result) > 2" { + eval "add_header('X-Spam-Llm-Result', 'Category=' + llm_result[0] + '; Confidence=' + llm_result[1] + '; Explanation=' + llm_result[2])"; + } +} diff --git a/tests/resources/jmap/sieve/test_mailbox.sieve b/tests/resources/jmap/sieve/test_mailbox.sieve index 4b8a975e..8d20830e 100644 --- a/tests/resources/jmap/sieve/test_mailbox.sieve +++ b/tests/resources/jmap/sieve/test_mailbox.sieve @@ -1,4 +1,4 @@ -require ["fileinto", "mailbox", "mailboxid", "special-use", "ihave", "imap4flags"]; +require ["fileinto", "mailbox", "mailboxid", "special-use", "ihave", "imap4flags", "vnd.stalwart.expressions"]; # SpecialUse extension tests if not specialuse_exists ["inbox", "trash"] { @@ -61,3 +61,6 @@ if not mailboxexists "My" { error "'My' not found."; } +if eval "llm_prompt('echo-test', 'hello world') != 'hello world'" { + error "llm_prompt is unavailable."; +} diff --git a/tests/resources/smtp/antispam/llm.test b/tests/resources/smtp/antispam/llm.test new file mode 100644 index 00000000..6fd6216c --- /dev/null +++ b/tests/resources/smtp/antispam/llm.test @@ -0,0 +1,72 @@ +expect LLM_UNSOLICITED_HIGH + +Subject: Unsolicited,High,Test + +Test + + +expect LLM_COMMERCIAL_HIGH + +Subject: Commercial,High,Test + +Test + +expect LLM_HARMFUL_HIGH + +Subject: Harmful,High,Test + +Test + +expect LLM_LEGITIMATE_HIGH + +Subject: Legitimate,High,Test + +Test + +expect LLM_UNSOLICITED_MEDIUM + +Subject: Unsolicited,Medium,Test + +Test + +expect LLM_COMMERCIAL_MEDIUM + +Subject: Commercial,Medium,Test + +Test + +expect LLM_HARMFUL_MEDIUM + +Subject: Harmful,Medium,Test + +Test + +expect LLM_LEGITIMATE_MEDIUM + +Subject: Legitimate,Medium,Test + +Test + +expect LLM_UNSOLICITED_LOW + +Subject: Unsolicited,Low,Test + +Test + +expect LLM_COMMERCIAL_LOW + +Subject: Commercial,Low,Test + +Test + +expect LLM_HARMFUL_LOW + +Subject: Harmful,Low,Test + +Test + +expect LLM_LEGITIMATE_LOW + +Subject: Legitimate,Low,Test + +Test diff --git a/tests/resources/smtp/lists/public-suffix.dat b/tests/resources/smtp/lists/public-suffix.dat deleted file mode 100644 index ecf5fe12..00000000 --- a/tests/resources/smtp/lists/public-suffix.dat +++ /dev/null @@ -1,10 +0,0 @@ -co.uk -org.uk -com -net -org -info -biz -*.wildcard -!test.wildcard -disposable.org diff --git a/tests/src/directory/oidc.rs b/tests/src/directory/oidc.rs index 08ffe016..876b4884 100644 --- a/tests/src/directory/oidc.rs +++ b/tests/src/directory/oidc.rs @@ -10,23 +10,18 @@ use std::sync::Arc; -use ahash::AHashMap; use base64::{engine::general_purpose, Engine}; -use common::{config::server::Listeners, listener::SessionData, Core, Data, Inner}; use directory::{backend::internal::PrincipalField, QueryBy}; -use hyper::{body, server::conn::http1, service::service_fn, Method, StatusCode, Uri}; -use hyper_util::rt::TokioIo; -use jmap::api::{ - http::{fetch_body, ToHttpResponse}, - HttpResponse, JsonResponse, -}; +use hyper::{Method, StatusCode}; +use jmap::api::{http::ToHttpResponse, JsonResponse}; use mail_send::Credentials; use serde_json::json; -use tokio::sync::watch; use trc::{AuthEvent, EventType}; -use utils::config::Config; -use crate::{add_test_certs, directory::DirectoryTest, AssertConfig}; +use crate::{ + directory::DirectoryTest, + http_server::{spawn_mock_http_server, HttpMessage}, +}; static TEST_TOKEN: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ"; @@ -143,121 +138,3 @@ async fn oidc_directory() { assert_eq!(principal.description(), Some("John Doe")); } } - -const MOCK_HTTP_SERVER: &str = r#" -[server] -hostname = "'oidc.example.org'" -http.url = "'https://127.0.0.1:9090'" - -[server.listener.jmap] -bind = ['127.0.0.1:9090'] -protocol = 'http' -tls.implicit = true - -[server.socket] -reuse-addr = true - -[certificate.default] -cert = '%{file:{CERT}}%' -private-key = '%{file:{PK}}%' -default = true -"#; - -#[derive(Clone)] -pub struct HttpSessionManager { - inner: HttpRequestHandler, -} - -pub type HttpRequestHandler = Arc HttpResponse + Sync + Send>; - -#[derive(Debug)] -pub struct HttpMessage { - method: Method, - headers: AHashMap, - uri: Uri, - body: Option>, -} - -impl HttpMessage { - pub fn get_url_encoded(&self, key: &str) -> Option { - form_urlencoded::parse(self.body.as_ref()?.as_slice()) - .find(|(k, _)| k == key) - .map(|(_, v)| v.into_owned()) - } -} - -pub async fn spawn_mock_http_server( - handler: HttpRequestHandler, -) -> (watch::Sender, watch::Receiver) { - // Start mock push server - let mut settings = Config::new(add_test_certs(MOCK_HTTP_SERVER)).unwrap(); - settings.resolve_all_macros().await; - let mock_inner = Arc::new(Inner { - shared_core: Core::parse(&mut settings, Default::default(), Default::default()) - .await - .into_shared(), - data: Data::parse(&mut settings), - ..Default::default() - }); - settings.errors.clear(); - settings.warnings.clear(); - let mut servers = Listeners::parse(&mut settings); - servers.parse_tcp_acceptors(&mut settings, mock_inner.clone()); - - // Start JMAP server - servers.bind_and_drop_priv(&mut settings); - settings.assert_no_errors(); - servers.spawn(|server, acceptor, shutdown_rx| { - server.spawn( - HttpSessionManager { - inner: handler.clone(), - }, - mock_inner.clone(), - acceptor, - shutdown_rx, - ); - }) -} - -impl common::listener::SessionManager for HttpSessionManager { - #[allow(clippy::manual_async_fn)] - fn handle( - self, - session: SessionData, - ) -> impl std::future::Future + Send { - async move { - let sender = self.inner; - let _ = http1::Builder::new() - .keep_alive(false) - .serve_connection( - TokioIo::new(session.stream), - service_fn(|mut req: hyper::Request| { - let sender = sender.clone(); - - async move { - let response = sender(HttpMessage { - method: req.method().clone(), - uri: req.uri().clone(), - headers: req - .headers() - .iter() - .map(|(k, v)| { - (k.as_str().to_lowercase(), v.to_str().unwrap().to_string()) - }) - .collect(), - body: fetch_body(&mut req, 1024 * 1024, 0).await, - }); - - Ok::<_, hyper::Error>(response.build()) - } - }), - ) - .await; - } - } - - #[allow(clippy::manual_async_fn)] - fn shutdown(&self) -> impl std::future::Future + Send { - async {} - } -} diff --git a/tests/src/http_server.rs b/tests/src/http_server.rs new file mode 100644 index 00000000..66dd477c --- /dev/null +++ b/tests/src/http_server.rs @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::Arc; + +use ahash::AHashMap; +use common::{config::server::Listeners, listener::SessionData, Core, Data, Inner}; +use hyper::{body, server::conn::http1, service::service_fn, Method, Uri}; +use hyper_util::rt::TokioIo; +use jmap::api::{http::fetch_body, HttpResponse}; +use tokio::sync::watch; +use utils::config::Config; + +use crate::{add_test_certs, AssertConfig}; + +const MOCK_HTTP_SERVER: &str = r#" +[server] +hostname = "'oidc.example.org'" +http.url = "'https://127.0.0.1:9090'" + +[server.listener.jmap] +bind = ['127.0.0.1:9090'] +protocol = 'http' +tls.implicit = true + +[server.socket] +reuse-addr = true + +[certificate.default] +cert = '%{file:{CERT}}%' +private-key = '%{file:{PK}}%' +default = true +"#; + +#[derive(Clone)] +pub struct HttpSessionManager { + inner: HttpRequestHandler, +} + +pub type HttpRequestHandler = Arc HttpResponse + Sync + Send>; + +#[derive(Debug)] +pub struct HttpMessage { + pub method: Method, + pub headers: AHashMap, + pub uri: Uri, + pub body: Option>, +} + +impl HttpMessage { + pub fn get_url_encoded(&self, key: &str) -> Option { + form_urlencoded::parse(self.body.as_ref()?.as_slice()) + .find(|(k, _)| k == key) + .map(|(_, v)| v.into_owned()) + } +} + +pub async fn spawn_mock_http_server( + handler: HttpRequestHandler, +) -> (watch::Sender, watch::Receiver) { + // Start mock push server + let mut settings = Config::new(add_test_certs(MOCK_HTTP_SERVER)).unwrap(); + settings.resolve_all_macros().await; + let mock_inner = Arc::new(Inner { + shared_core: Core::parse(&mut settings, Default::default(), Default::default()) + .await + .into_shared(), + data: Data::parse(&mut settings), + ..Default::default() + }); + settings.errors.clear(); + settings.warnings.clear(); + let mut servers = Listeners::parse(&mut settings); + servers.parse_tcp_acceptors(&mut settings, mock_inner.clone()); + + // Start JMAP server + servers.bind_and_drop_priv(&mut settings); + settings.assert_no_errors(); + servers.spawn(|server, acceptor, shutdown_rx| { + server.spawn( + HttpSessionManager { + inner: handler.clone(), + }, + mock_inner.clone(), + acceptor, + shutdown_rx, + ); + }) +} + +impl common::listener::SessionManager for HttpSessionManager { + #[allow(clippy::manual_async_fn)] + fn handle( + self, + session: SessionData, + ) -> impl std::future::Future + Send { + async move { + let sender = self.inner; + let _ = http1::Builder::new() + .keep_alive(false) + .serve_connection( + TokioIo::new(session.stream), + service_fn(|mut req: hyper::Request| { + let sender = sender.clone(); + + async move { + let response = sender(HttpMessage { + method: req.method().clone(), + uri: req.uri().clone(), + headers: req + .headers() + .iter() + .map(|(k, v)| { + (k.as_str().to_lowercase(), v.to_str().unwrap().to_string()) + }) + .collect(), + body: fetch_body(&mut req, 1024 * 1024, 0).await, + }); + + Ok::<_, hyper::Error>(response.build()) + } + }), + ) + .await; + } + } + + #[allow(clippy::manual_async_fn)] + fn shutdown(&self) -> impl std::future::Future + Send { + async {} + } +} diff --git a/tests/src/jmap/enterprise.rs b/tests/src/jmap/enterprise.rs index a04b73a3..b5bdb3a2 100644 --- a/tests/src/jmap/enterprise.rs +++ b/tests/src/jmap/enterprise.rs @@ -106,6 +106,7 @@ pub async fn test(params: &mut JMAPTest) { .into(), metrics_alerts: parse_metric_alerts(&mut config), logo_url: None, + ai_apis: Default::default(), } .into(); config.assert_no_errors(); @@ -170,6 +171,7 @@ impl EnterpriseCore for Core { metrics_store: None, metrics_alerts: vec![], logo_url: None, + ai_apis: Default::default(), } .into(); self diff --git a/tests/src/jmap/mod.rs b/tests/src/jmap/mod.rs index 58f4ba52..a5feeb7d 100644 --- a/tests/src/jmap/mod.rs +++ b/tests/src/jmap/mod.rs @@ -384,12 +384,12 @@ pub async fn jmap_tests() { mailbox::test(&mut params).await; delivery::test(&mut params).await; auth_acl::test(&mut params).await; - auth_limits::test(&mut params).await;*/ + auth_limits::test(&mut params).await; auth_oauth::test(&mut params).await; - /*event_source::test(&mut params).await; - push_subscription::test(&mut params).await; + event_source::test(&mut params).await; + push_subscription::test(&mut params).await;*/ sieve_script::test(&mut params).await; - vacation_response::test(&mut params).await; + /*vacation_response::test(&mut params).await; email_submission::test(&mut params).await; websocket::test(&mut params).await; quota::test(&mut params).await; diff --git a/tests/src/lib.rs b/tests/src/lib.rs index 0aaf2541..dd1f32fa 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -18,6 +18,8 @@ static GLOBAL: Jemalloc = Jemalloc; #[cfg(test)] pub mod directory; #[cfg(test)] +pub mod http_server; +#[cfg(test)] pub mod imap; #[cfg(test)] pub mod jmap; diff --git a/tests/src/smtp/inbound/antispam.rs b/tests/src/smtp/inbound/antispam.rs index b8b42acc..0eb9e577 100644 --- a/tests/src/smtp/inbound/antispam.rs +++ b/tests/src/smtp/inbound/antispam.rs @@ -10,12 +10,17 @@ use std::{ use ahash::AHashMap; use common::{ auth::AccessToken, + enterprise::llm::{ + AiApiConfig, ChatCompletionChoice, ChatCompletionRequest, ChatCompletionResponse, Message, + }, scripts::{ functions::html::{get_attribute, html_attr_tokens, html_img_area, html_to_tokens}, ScriptModification, }, Core, }; +use hyper::Method; +use jmap::api::{http::ToHttpResponse, JsonResponse}; use mail_auth::{dmarc::Policy, DkimResult, DmarcResult, IprevResult, SpfResult, MX}; use sieve::runtime::Variable; use smtp::{ @@ -26,7 +31,11 @@ use smtp::{ use store::Stores; use utils::config::Config; -use crate::smtp::{session::TestSession, TempDir, TestSMTP}; +use crate::{ + http_server::{spawn_mock_http_server, HttpMessage}, + jmap::enterprise::EnterpriseCore, + smtp::{session::TestSession, TempDir, TestSMTP}, +}; const CONFIG: &str = r#" [spam.header] @@ -46,6 +55,10 @@ threshold-discard = 0 threshold-reject = 0 directory = "" lookup = "" +llm-model = "dummy" +llm-prompt = "You are an AI assistant specialized in analyzing email content to detect unsolicited, commercial, or harmful messages. Format your response as follows, separated by commas: Category,Confidence,Explanation +Here's the email to analyze, please provide your analysis based on the above instructions, ensuring your response is in the specified comma-separated format:" +add-llm-result = false [session.rcpt] relay = true @@ -70,6 +83,11 @@ data = "spamdb" lookup = "spamdb" blob = "spamdb" fts = "spamdb" +directory = "spamdb" + +[directory."spamdb"] +type = "internal" +store = "spamdb" [store."spamdb"] type = "sqlite" @@ -79,6 +97,12 @@ path = "{PATH}/test_antispam.db" #type = "redis" #url = "redis://127.0.0.1" +[enterprise.ai.dummy] +endpoint = "https://127.0.0.1:9090/v1/chat/completions" +type = "chat" +model = "gpt-dummy" +allow-invalid-certs = true + [lookup] "spam-free" = {"gmail.com", "googlemail.com", "yahoomail.com", "*freemail.org"} "spam-disposable" = {"guerrillamail.com", "*disposable.org"} @@ -94,9 +118,6 @@ path = "{PATH}/test_antispam.db" "spam-trap" = {"spamtrap@*"} "spam-allow" = {"stalw.art"} -[resolver] -public-suffix = "file://{LIST_PATH}/public-suffix.dat" - [sieve.trusted.scripts] "#; @@ -129,6 +150,7 @@ async fn antispam() { "bayes_classify", "reputation", "pyzor", + "llm", ]; let tmp_dir = TempDir::new("smtp_antispam_test", true); let base_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -192,8 +214,14 @@ async fn antispam() { let mut config = Config::new(&config).unwrap(); config.resolve_all_macros().await; let stores = Stores::parse_all(&mut config).await; - let core = Core::parse(&mut config, stores, Default::default()).await; - //config.assert_no_errors(); + let mut core = Core::parse(&mut config, stores, Default::default()) + .await + .enable_enterprise(); + core.enterprise.as_mut().unwrap().ai_apis.insert( + "dummy".to_string(), + AiApiConfig::parse(&mut config, "dummy").unwrap(), + ); + crate::AssertConfig::assert_no_errors(config); // Add mock DNS entries for (domain, ip) in [ @@ -252,6 +280,34 @@ async fn antispam() { let server = TestSMTP::from_core(core).server; + // Spawn mock OpenAI server + let _tx = spawn_mock_http_server(Arc::new(|req: HttpMessage| { + assert_eq!(req.uri.path(), "/v1/chat/completions"); + assert_eq!(req.method, Method::POST); + let req = + serde_json::from_slice::(req.body.as_ref().unwrap()).unwrap(); + assert_eq!(req.model, "gpt-dummy"); + let message = &req.messages[0].content; + assert!(message.contains("You are an AI assistant specialized in analyzing email")); + + JsonResponse::new(&ChatCompletionResponse { + created: 0, + object: String::new(), + id: String::new(), + model: req.model, + choices: vec![ChatCompletionChoice { + index: 0, + finish_reason: "stop".to_string(), + message: Message { + role: "assistant".to_string(), + content: message.split_once("Subject: ").unwrap().1.to_string(), + }, + }], + }) + .into_http_response() + })) + .await; + // Run tests let base_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("resources") @@ -420,10 +476,7 @@ async fn antispam() { // Run script let server_ = server.clone(); let script = script.clone(); - match server_ - .run_script("test".to_string(), script, params, 0) - .await - { + match server_.run_script("test".to_string(), script, params).await { ScriptResult::Accept { modifications } => { if modifications.len() != expected_headers.len() { panic!( diff --git a/tests/src/smtp/inbound/scripts.rs b/tests/src/smtp/inbound/scripts.rs index 4833aad9..951b631a 100644 --- a/tests/src/smtp/inbound/scripts.rs +++ b/tests/src/smtp/inbound/scripts.rs @@ -177,7 +177,7 @@ async fn sieve_scripts() { .await; match test .server - .run_script(name.to_string(), script, params, 0) + .run_script(name.to_string(), script, params) .await { ScriptResult::Accept { .. } => (),