From 199b9b79d24080a3d70fb6a6aaafc23e394428c3 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 10 Feb 2026 16:06:04 +0000 Subject: [PATCH] feat(rust): implement mailer-core and mailer-security crates with CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rust migration Phase 1 — implements real functionality in the previously stubbed mailer-core and mailer-security crates (38 passing tests). mailer-core: Email/EmailAddress/Attachment types, RFC 5322 MIME builder, email format validation with scoring, bounce detection (14 types, 40+ regex patterns), DSN status parsing, retry delay calculation. mailer-security: DKIM signing (RSA-SHA256) and verification, SPF checking, DMARC verification with public suffix list, DNSBL IP reputation checking (10 default servers, parallel queries), all powered by mail-auth 0.7. mailer-bin: Full CLI with validate/bounce/check-ip/verify-email/dkim-sign subcommands plus --management mode for smartrust JSON-over-stdin/stdout IPC. --- rust/Cargo.lock | 551 +++++++++-------- rust/crates/mailer-bin/Cargo.toml | 1 + rust/crates/mailer-bin/src/main.rs | 563 +++++++++++++++++- rust/crates/mailer-core/src/bounce.rs | 485 +++++++++++++++ rust/crates/mailer-core/src/email.rs | 411 +++++++++++++ rust/crates/mailer-core/src/error.rs | 31 + rust/crates/mailer-core/src/lib.rs | 18 +- rust/crates/mailer-core/src/mime.rs | 377 ++++++++++++ rust/crates/mailer-core/src/validation.rs | 178 ++++++ rust/crates/mailer-security/src/dkim.rs | 148 +++++ rust/crates/mailer-security/src/dmarc.rs | 127 ++++ rust/crates/mailer-security/src/error.rs | 31 + .../mailer-security/src/ip_reputation.rs | 280 +++++++++ rust/crates/mailer-security/src/lib.rs | 28 +- rust/crates/mailer-security/src/spf.rs | 145 +++++ 15 files changed, 3113 insertions(+), 261 deletions(-) create mode 100644 rust/crates/mailer-core/src/bounce.rs create mode 100644 rust/crates/mailer-core/src/email.rs create mode 100644 rust/crates/mailer-core/src/error.rs create mode 100644 rust/crates/mailer-core/src/mime.rs create mode 100644 rust/crates/mailer-core/src/validation.rs create mode 100644 rust/crates/mailer-security/src/dkim.rs create mode 100644 rust/crates/mailer-security/src/dmarc.rs create mode 100644 rust/crates/mailer-security/src/error.rs create mode 100644 rust/crates/mailer-security/src/ip_reputation.rs create mode 100644 rust/crates/mailer-security/src/spf.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index ccde594..20af019 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -41,6 +41,56 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -83,12 +133,6 @@ dependencies = [ "fs_extra", ] -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" @@ -116,12 +160,6 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.11.1" @@ -130,21 +168,11 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bzip2" -version = "0.5.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" dependencies = [ - "bzip2-sys", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", + "libbz2-rs-sys", ] [[package]] @@ -171,7 +199,7 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e" dependencies = [ - "base64 0.22.1", + "base64", "encoding_rs", ] @@ -185,6 +213,46 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + [[package]] name = "cmake" version = "0.1.57" @@ -194,6 +262,12 @@ dependencies = [ "cc", ] +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -417,6 +491,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -486,12 +561,12 @@ dependencies = [ [[package]] name = "gethostname" -version = "0.4.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "libc", - "windows-targets 0.48.5", + "rustix", + "windows-link", ] [[package]] @@ -512,11 +587,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi", "wasip2", - "wasm-bindgen", ] [[package]] @@ -531,40 +604,23 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashify" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149e3ea90eb5a26ad354cfe3cb7f7401b9329032d0235f2687d03a35f30e5d4c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hickory-proto" -version = "0.24.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" -dependencies = [ - "async-trait", - "cfg-if", - "data-encoding", - "enum-as-inner", - "futures-channel", - "futures-io", - "futures-util", - "idna", - "ipnet", - "once_cell", - "rand 0.8.5", - "ring", - "rustls 0.21.12", - "rustls-pemfile 1.0.4", - "thiserror 1.0.69", - "tinyvec", - "tokio", - "tokio-rustls 0.24.1", - "tracing", - "url", -] - [[package]] name = "hickory-proto" version = "0.25.2" @@ -581,9 +637,9 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand 0.9.2", + "rand", "ring", - "thiserror 2.0.18", + "thiserror", "tinyvec", "tokio", "tracing", @@ -591,26 +647,34 @@ dependencies = [ ] [[package]] -name = "hickory-resolver" -version = "0.24.4" +name = "hickory-proto" +version = "0.26.0-alpha.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" +checksum = "a62d7684f766b0f96344be88c023f9b6650039aea09d526b4974cce302eb61b1" dependencies = [ + "async-trait", + "bitflags", + "bytes", "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", "futures-util", - "hickory-proto 0.24.4", - "ipconfig", - "lru-cache", + "idna", + "ipnet", "once_cell", - "parking_lot", - "rand 0.8.5", - "resolv-conf", - "rustls 0.21.12", - "smallvec", - "thiserror 1.0.69", + "rand", + "ring", + "rustls", + "rustls-pki-types", + "thiserror", + "time", + "tinyvec", "tokio", - "tokio-rustls 0.24.1", + "tokio-rustls", "tracing", + "url", ] [[package]] @@ -626,14 +690,37 @@ dependencies = [ "moka", "once_cell", "parking_lot", - "rand 0.9.2", + "rand", "resolv-conf", "smallvec", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", ] +[[package]] +name = "hickory-resolver" +version = "0.26.0-alpha.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbab5e26a7f82341145ba1fbd1f1858d0490624fcc46270db2d3c4a101f763f4" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto 0.26.0-alpha.1", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand", + "resolv-conf", + "rustls", + "smallvec", + "thiserror", + "tokio", + "tokio-rustls", + "tracing", +] + [[package]] name = "hmac" version = "0.12.1" @@ -782,6 +869,12 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.17" @@ -808,6 +901,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" + [[package]] name = "libc" version = "0.2.181" @@ -825,10 +924,10 @@ dependencies = [ ] [[package]] -name = "linked-hash-map" -version = "0.5.6" +name = "linux-raw-sys" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" @@ -852,51 +951,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] -name = "lru-cache" -version = "0.1.2" +name = "lzma-rust2" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +checksum = "c60a23ffb90d527e23192f1246b14746e2f7f071cb84476dd879071696c18a4a" dependencies = [ - "linked-hash-map", -] - -[[package]] -name = "lzma-rs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" -dependencies = [ - "byteorder", "crc", -] - -[[package]] -name = "lzma-sys" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" -dependencies = [ - "cc", - "libc", - "pkg-config", + "sha2", ] [[package]] name = "mail-auth" -version = "0.4.3" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bd9d657de66a3d5ac360c3eab8c9f5cac2565f2b97cc032d5de4c900ef470de" +checksum = "5b7da45f78cc525d3750b623c967ae21c0cd28b2e6a9a2ee4b536a7cce3b21ce" dependencies = [ "ahash", "flate2", - "hickory-resolver 0.24.4", - "lru-cache", + "hashify", + "hickory-resolver 0.26.0-alpha.1", "mail-builder", "mail-parser", - "parking_lot", "quick-xml", + "quick_cache", "ring", - "rustls-pemfile 2.2.0", + "rustls-pki-types", "serde", "serde_json", "zip", @@ -904,26 +983,29 @@ dependencies = [ [[package]] name = "mail-builder" -version = "0.3.2" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25f5871d5270ed80f2ee750b95600c8d69b05f8653ad3be913b2ad2e924fefcb" +checksum = "900998f307338c4013a28ab14d760b784067324b164448c6d98a89e44810473b" dependencies = [ "gethostname", ] [[package]] name = "mail-parser" -version = "0.9.4" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93c3b9e5d8b17faf573330bbc43b37d6e918c0a3bf8a88e7d0a220ebc84af9fc" +checksum = "dcf4390741c4e6fa330bdeccdfb580815dbb462952de91838b723357985119a3" dependencies = [ "encoding_rs", + "hashify", ] [[package]] name = "mailer-bin" version = "0.1.0" dependencies = [ + "clap", + "hickory-resolver 0.25.2", "mailer-core", "mailer-security", "mailer-smtp", @@ -937,12 +1019,15 @@ dependencies = [ name = "mailer-core" version = "0.1.0" dependencies = [ + "base64", "bytes", "mailparse", + "regex", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "tracing", + "uuid", ] [[package]] @@ -964,11 +1049,17 @@ dependencies = [ name = "mailer-security" version = "0.1.0" dependencies = [ + "hickory-resolver 0.25.2", + "ipnet", "mail-auth", "mailer-core", + "psl", "ring", + "rustls-pki-types", "serde", - "thiserror 2.0.18", + "serde_json", + "thiserror", + "tokio", "tracing", ] @@ -981,17 +1072,17 @@ dependencies = [ "hickory-resolver 0.25.2", "mailer-core", "serde", - "thiserror 2.0.18", + "thiserror", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tracing", ] [[package]] name = "mailparse" -version = "0.15.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3da03d5980411a724e8aaf7b61a7b5e386ec55a7fb49ee3d0ff79efc7e5e7c7e" +checksum = "60819a97ddcb831a5614eb3b0174f3620e793e97e09195a395bfa948fd68ed2f" dependencies = [ "charset", "data-encoding", @@ -1118,6 +1209,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1196,6 +1293,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppmd-rust" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1215,14 +1318,41 @@ dependencies = [ ] [[package]] -name = "quick-xml" -version = "0.32.0" +name = "psl" +version = "2.1.188" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2" +checksum = "b033d75bca9da25cfdcd9528de22ed7870d1695b9e1c3ce55b7127a4a2b16fac" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" dependencies = [ "memchr", ] +[[package]] +name = "quick_cache" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ada44a88ef953a3294f6eb55d2007ba44646015e18613d2f213016379203ef3" +dependencies = [ + "ahash", + "equivalent", + "hashbrown 0.16.1", + "parking_lot", +] + [[package]] name = "quote" version = "1.0.44" @@ -1244,35 +1374,14 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", + "rand_chacha", + "rand_core", ] [[package]] @@ -1282,16 +1391,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", + "rand_core", ] [[package]] @@ -1362,15 +1462,16 @@ dependencies = [ ] [[package]] -name = "rustls" -version = "0.21.12" +name = "rustix" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1382,30 +1483,13 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", + "ring", "rustls-pki-types", - "rustls-webpki 0.103.9", + "rustls-webpki", "subtle", "zeroize", ] -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", -] - -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -1415,16 +1499,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-webpki" -version = "0.101.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "rustls-webpki" version = "0.103.9" @@ -1449,16 +1523,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "semver" version = "1.0.27" @@ -1519,6 +1583,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1579,6 +1654,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -1613,33 +1694,13 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -1725,23 +1786,13 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.36", + "rustls", "tokio", ] @@ -1818,6 +1869,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.20.0" @@ -2151,15 +2208,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" -[[package]] -name = "xz2" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" -dependencies = [ - "lzma-sys", -] - [[package]] name = "yoke" version = "0.8.1" @@ -2279,34 +2327,37 @@ dependencies = [ [[package]] name = "zip" -version = "2.4.2" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" dependencies = [ "aes", "arbitrary", "bzip2", "constant_time_eq", "crc32fast", - "crossbeam-utils", "deflate64", - "displaydoc", "flate2", "getrandom 0.3.4", "hmac", "indexmap", - "lzma-rs", + "lzma-rust2", "memchr", "pbkdf2", + "ppmd-rust", "sha1", - "thiserror 2.0.18", "time", - "xz2", "zeroize", "zopfli", "zstd", ] +[[package]] +name = "zlib-rs" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c" + [[package]] name = "zmij" version = "1.0.20" diff --git a/rust/crates/mailer-bin/Cargo.toml b/rust/crates/mailer-bin/Cargo.toml index b80f410..a878ede 100644 --- a/rust/crates/mailer-bin/Cargo.toml +++ b/rust/crates/mailer-bin/Cargo.toml @@ -17,3 +17,4 @@ tracing.workspace = true serde.workspace = true serde_json.workspace = true clap.workspace = true +hickory-resolver.workspace = true diff --git a/rust/crates/mailer-bin/src/main.rs b/rust/crates/mailer-bin/src/main.rs index a013eb1..ecbc825 100644 --- a/rust/crates/mailer-bin/src/main.rs +++ b/rust/crates/mailer-bin/src/main.rs @@ -1,11 +1,558 @@ -//! mailer-bin: Standalone Rust binary for the @serve.zone/mailer network stack. +//! mailer-bin: CLI and IPC binary for the @serve.zone/mailer Rust crates. +//! +//! Supports two modes: +//! 1. **CLI mode** — traditional subcommands for testing and standalone use +//! 2. **Management mode** (`--management`) — JSON-over-stdin/stdout IPC for +//! integration with `@push.rocks/smartrust` from TypeScript + +use clap::{Parser, Subcommand}; +use serde::{Deserialize, Serialize}; +use std::io::{self, BufRead, Write}; +use std::net::IpAddr; + +/// mailer-bin: Rust-powered email security tools +#[derive(Parser)] +#[command(name = "mailer-bin", version, about)] +struct Cli { + #[command(subcommand)] + command: Option, + + /// Run in management/IPC mode (JSON-over-stdin/stdout for smartrust bridge) + #[arg(long)] + management: bool, +} + +#[derive(Subcommand)] +enum Commands { + /// Print version information + Version, + + /// Validate an email address + Validate { + /// The email address to validate + email: String, + }, + + /// Detect bounce type from an SMTP response + Bounce { + /// The SMTP response or diagnostic message + message: String, + }, + + /// Check IP reputation via DNSBL + CheckIp { + /// The IP address to check + ip: String, + }, + + /// Verify DKIM/SPF/DMARC for an email (reads raw message from stdin) + VerifyEmail { + /// Sender IP address for SPF check + #[arg(long)] + ip: Option, + + /// HELO domain for SPF check + #[arg(long)] + helo: Option, + + /// Receiving server hostname + #[arg(long, default_value = "localhost")] + hostname: String, + + /// MAIL FROM address for SPF check + #[arg(long)] + mail_from: Option, + }, + + /// Sign an email with DKIM (reads raw message from stdin) + DkimSign { + /// Signing domain + #[arg(long)] + domain: String, + + /// DKIM selector + #[arg(long, default_value = "mta")] + selector: String, + + /// Path to RSA private key PEM file + #[arg(long)] + key: String, + }, +} + +// --- IPC types for smartrust bridge --- + +#[derive(Deserialize)] +struct IpcRequest { + id: String, + method: String, + params: serde_json::Value, +} + +#[derive(Serialize)] +struct IpcResponse { + id: String, + success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Serialize)] +struct IpcEvent { + event: String, + data: serde_json::Value, +} fn main() { - println!( - "mailer-bin v{} (core: {}, smtp: {}, security: {})", - env!("CARGO_PKG_VERSION"), - mailer_core::version(), - mailer_smtp::version(), - mailer_security::version(), - ); + let cli = Cli::parse(); + + if cli.management { + run_management_mode(); + return; + } + + match cli.command { + Some(Commands::Version) | None => { + println!( + "mailer-bin v{} (core: {}, smtp: {}, security: {})", + env!("CARGO_PKG_VERSION"), + mailer_core::version(), + mailer_smtp::version(), + mailer_security::version(), + ); + } + + Some(Commands::Validate { email }) => { + let result = mailer_core::validate_email(&email); + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "email": email, + "valid": result.is_valid, + "formatValid": result.format_valid, + "score": result.score, + "error": result.error_message, + })) + .unwrap() + ); + } + + Some(Commands::Bounce { message }) => { + let detection = mailer_core::detect_bounce_type(Some(&message), None, None); + println!( + "{}", + serde_json::to_string_pretty(&detection).unwrap() + ); + } + + Some(Commands::CheckIp { ip }) => { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let ip_addr: IpAddr = match ip.parse() { + Ok(addr) => addr, + Err(e) => { + eprintln!("Invalid IP address: {}", e); + std::process::exit(1); + } + }; + + let resolver = hickory_resolver::TokioResolver::builder_tokio().map(|b| b.build()) + .expect("Failed to create DNS resolver"); + + match mailer_security::check_reputation( + ip_addr, + mailer_security::DEFAULT_DNSBL_SERVERS, + &resolver, + ) + .await + { + Ok(result) => { + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + } + Err(e) => { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } + }); + } + + Some(Commands::VerifyEmail { + ip, + helo, + hostname, + mail_from, + }) => { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + // Read raw message from stdin + let mut raw_message = Vec::new(); + io::stdin() + .lock() + .read_to_end(&mut raw_message) + .expect("Failed to read from stdin"); + + let authenticator = mailer_security::default_authenticator() + .expect("Failed to create authenticator"); + + // DKIM verification + let dkim_results = mailer_security::verify_dkim(&raw_message, &authenticator) + .await + .unwrap_or_else(|e| { + vec![mailer_security::DkimVerificationResult { + is_valid: false, + domain: None, + selector: None, + status: "error".to_string(), + details: Some(e.to_string()), + }] + }); + + let mut output = serde_json::json!({ + "dkim": dkim_results, + }); + + // SPF verification (if IP provided) + if let (Some(ip_str), Some(helo_domain), Some(sender)) = + (&ip, &helo, &mail_from) + { + if let Ok(ip_addr) = ip_str.parse::() { + match mailer_security::check_spf( + ip_addr, + helo_domain, + &hostname, + sender, + &authenticator, + ) + .await + { + Ok(spf_result) => { + output["spf"] = serde_json::to_value(&spf_result).unwrap(); + } + Err(e) => { + output["spf"] = + serde_json::json!({"error": e.to_string()}); + } + } + } + } + + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + }); + } + + Some(Commands::DkimSign { + domain, + selector, + key, + }) => { + // Read private key + let key_pem = std::fs::read_to_string(&key).unwrap_or_else(|e| { + eprintln!("Failed to read key file '{}': {}", key, e); + std::process::exit(1); + }); + + // Read raw message from stdin + let mut raw_message = Vec::new(); + io::stdin() + .lock() + .read_to_end(&mut raw_message) + .expect("Failed to read from stdin"); + + match mailer_security::sign_dkim(&raw_message, &domain, &selector, &key_pem) { + Ok(header) => { + // Output signed message: DKIM header + original message + print!("{}", header); + io::stdout().write_all(&raw_message).unwrap(); + } + Err(e) => { + eprintln!("DKIM signing failed: {}", e); + std::process::exit(1); + } + } + } + } +} + +use std::io::Read; + +/// Run in management/IPC mode for smartrust bridge. +fn run_management_mode() { + // Signal readiness + let ready_event = IpcEvent { + event: "ready".to_string(), + data: serde_json::json!({ + "version": env!("CARGO_PKG_VERSION"), + "core_version": mailer_core::version(), + "security_version": mailer_security::version(), + }), + }; + println!("{}", serde_json::to_string(&ready_event).unwrap()); + io::stdout().flush().unwrap(); + + let rt = tokio::runtime::Runtime::new().unwrap(); + + let stdin = io::stdin(); + for line in stdin.lock().lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, + }; + + if line.trim().is_empty() { + continue; + } + + let req: IpcRequest = match serde_json::from_str(&line) { + Ok(r) => r, + Err(e) => { + let resp = IpcResponse { + id: "unknown".to_string(), + success: false, + result: None, + error: Some(format!("Invalid request: {}", e)), + }; + println!("{}", serde_json::to_string(&resp).unwrap()); + io::stdout().flush().unwrap(); + continue; + } + }; + + let response = rt.block_on(handle_ipc_request(&req)); + println!("{}", serde_json::to_string(&response).unwrap()); + io::stdout().flush().unwrap(); + } +} + +async fn handle_ipc_request(req: &IpcRequest) -> IpcResponse { + match req.method.as_str() { + "ping" => IpcResponse { + id: req.id.clone(), + success: true, + result: Some(serde_json::json!({"pong": true})), + error: None, + }, + + "version" => IpcResponse { + id: req.id.clone(), + success: true, + result: Some(serde_json::json!({ + "bin": env!("CARGO_PKG_VERSION"), + "core": mailer_core::version(), + "security": mailer_security::version(), + "smtp": mailer_smtp::version(), + })), + error: None, + }, + + "validateEmail" => { + let email = req.params.get("email").and_then(|v| v.as_str()).unwrap_or(""); + let result = mailer_core::validate_email(email); + IpcResponse { + id: req.id.clone(), + success: true, + result: Some(serde_json::json!({ + "valid": result.is_valid, + "formatValid": result.format_valid, + "score": result.score, + "error": result.error_message, + })), + error: None, + } + } + + "detectBounce" => { + let smtp_response = req.params.get("smtpResponse").and_then(|v| v.as_str()); + let diagnostic = req.params.get("diagnosticCode").and_then(|v| v.as_str()); + let status = req.params.get("statusCode").and_then(|v| v.as_str()); + let detection = mailer_core::detect_bounce_type(smtp_response, diagnostic, status); + IpcResponse { + id: req.id.clone(), + success: true, + result: Some(serde_json::to_value(&detection).unwrap()), + error: None, + } + } + + "checkIpReputation" => { + let ip_str = req.params.get("ip").and_then(|v| v.as_str()).unwrap_or(""); + match ip_str.parse::() { + Ok(ip_addr) => { + let resolver = match hickory_resolver::TokioResolver::builder_tokio().map(|b| b.build()) { + Ok(r) => r, + Err(e) => { + return IpcResponse { + id: req.id.clone(), + success: false, + result: None, + error: Some(format!("DNS resolver error: {}", e)), + }; + } + }; + match mailer_security::check_reputation( + ip_addr, + mailer_security::DEFAULT_DNSBL_SERVERS, + &resolver, + ) + .await + { + Ok(result) => IpcResponse { + id: req.id.clone(), + success: true, + result: Some(serde_json::to_value(&result).unwrap()), + error: None, + }, + Err(e) => IpcResponse { + id: req.id.clone(), + success: false, + result: None, + error: Some(e.to_string()), + }, + } + } + Err(e) => IpcResponse { + id: req.id.clone(), + success: false, + result: None, + error: Some(format!("Invalid IP address: {}", e)), + }, + } + } + + "verifyDkim" => { + let raw_message = req + .params + .get("rawMessage") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let authenticator = match mailer_security::default_authenticator() { + Ok(a) => a, + Err(e) => { + return IpcResponse { + id: req.id.clone(), + success: false, + result: None, + error: Some(format!("Authenticator error: {}", e)), + }; + } + }; + match mailer_security::verify_dkim(raw_message.as_bytes(), &authenticator).await { + Ok(results) => IpcResponse { + id: req.id.clone(), + success: true, + result: Some(serde_json::to_value(&results).unwrap()), + error: None, + }, + Err(e) => IpcResponse { + id: req.id.clone(), + success: false, + result: None, + error: Some(e.to_string()), + }, + } + } + + "signDkim" => { + let raw_message = req + .params + .get("rawMessage") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let domain = req.params.get("domain").and_then(|v| v.as_str()).unwrap_or(""); + let selector = req + .params + .get("selector") + .and_then(|v| v.as_str()) + .unwrap_or("mta"); + let private_key = req + .params + .get("privateKey") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + match mailer_security::sign_dkim(raw_message.as_bytes(), domain, selector, private_key) { + Ok(header) => IpcResponse { + id: req.id.clone(), + success: true, + result: Some(serde_json::json!({ + "header": header, + "signedMessage": format!("{}{}", header, raw_message), + })), + error: None, + }, + Err(e) => IpcResponse { + id: req.id.clone(), + success: false, + result: None, + error: Some(e.to_string()), + }, + } + } + + "checkSpf" => { + let ip_str = req.params.get("ip").and_then(|v| v.as_str()).unwrap_or(""); + let helo = req + .params + .get("heloDomain") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let hostname = req + .params + .get("hostname") + .and_then(|v| v.as_str()) + .unwrap_or("localhost"); + let mail_from = req + .params + .get("mailFrom") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + match ip_str.parse::() { + Ok(ip_addr) => { + let authenticator = match mailer_security::default_authenticator() { + Ok(a) => a, + Err(e) => { + return IpcResponse { + id: req.id.clone(), + success: false, + result: None, + error: Some(format!("Authenticator error: {}", e)), + }; + } + }; + match mailer_security::check_spf(ip_addr, helo, hostname, mail_from, &authenticator) + .await + { + Ok(result) => IpcResponse { + id: req.id.clone(), + success: true, + result: Some(serde_json::to_value(&result).unwrap()), + error: None, + }, + Err(e) => IpcResponse { + id: req.id.clone(), + success: false, + result: None, + error: Some(e.to_string()), + }, + } + } + Err(e) => IpcResponse { + id: req.id.clone(), + success: false, + result: None, + error: Some(format!("Invalid IP address: {}", e)), + }, + } + } + + _ => IpcResponse { + id: req.id.clone(), + success: false, + result: None, + error: Some(format!("Unknown method: {}", req.method)), + }, + } } diff --git a/rust/crates/mailer-core/src/bounce.rs b/rust/crates/mailer-core/src/bounce.rs new file mode 100644 index 0000000..6ceb2e3 --- /dev/null +++ b/rust/crates/mailer-core/src/bounce.rs @@ -0,0 +1,485 @@ +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::sync::LazyLock; + +/// Type of email bounce. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BounceType { + // Hard bounces + InvalidRecipient, + DomainNotFound, + MailboxFull, + MailboxInactive, + Blocked, + SpamRelated, + PolicyRelated, + // Soft bounces + ServerUnavailable, + TemporaryFailure, + QuotaExceeded, + NetworkError, + Timeout, + // Special + AutoResponse, + ChallengeResponse, + Unknown, +} + +/// Broad category of a bounce. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BounceCategory { + Hard, + Soft, + AutoResponse, + Unknown, +} + +impl BounceType { + /// Get the category for this bounce type. + pub fn category(&self) -> BounceCategory { + match self { + BounceType::InvalidRecipient + | BounceType::DomainNotFound + | BounceType::MailboxFull + | BounceType::MailboxInactive + | BounceType::Blocked + | BounceType::SpamRelated + | BounceType::PolicyRelated => BounceCategory::Hard, + + BounceType::ServerUnavailable + | BounceType::TemporaryFailure + | BounceType::QuotaExceeded + | BounceType::NetworkError + | BounceType::Timeout => BounceCategory::Soft, + + BounceType::AutoResponse | BounceType::ChallengeResponse => { + BounceCategory::AutoResponse + } + + BounceType::Unknown => BounceCategory::Unknown, + } + } +} + +/// Result of bounce detection. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BounceDetection { + pub bounce_type: BounceType, + pub category: BounceCategory, +} + +/// Pattern set for a bounce type: compiled regexes for matching against SMTP responses. +struct BouncePatterns { + bounce_type: BounceType, + patterns: Vec, +} + +/// All bounce detection patterns, compiled once. +static BOUNCE_PATTERNS: LazyLock> = LazyLock::new(|| { + vec![ + BouncePatterns { + bounce_type: BounceType::InvalidRecipient, + patterns: compile_patterns(&[ + r"(?i)no such user", + r"(?i)user unknown", + r"(?i)does not exist", + r"(?i)invalid recipient", + r"(?i)unknown recipient", + r"(?i)no mailbox", + r"(?i)user not found", + r"(?i)recipient address rejected", + r"(?i)550 5\.1\.1", + ]), + }, + BouncePatterns { + bounce_type: BounceType::DomainNotFound, + patterns: compile_patterns(&[ + r"(?i)domain not found", + r"(?i)unknown domain", + r"(?i)no such domain", + r"(?i)host not found", + r"(?i)domain invalid", + r"(?i)550 5\.1\.2", + ]), + }, + BouncePatterns { + bounce_type: BounceType::MailboxFull, + patterns: compile_patterns(&[ + r"(?i)mailbox full", + r"(?i)over quota", + r"(?i)quota exceeded", + r"(?i)552 5\.2\.2", + ]), + }, + BouncePatterns { + bounce_type: BounceType::MailboxInactive, + patterns: compile_patterns(&[ + r"(?i)mailbox disabled", + r"(?i)mailbox inactive", + r"(?i)account disabled", + r"(?i)mailbox not active", + r"(?i)account suspended", + ]), + }, + BouncePatterns { + bounce_type: BounceType::Blocked, + patterns: compile_patterns(&[ + r"(?i)blocked", + r"(?i)rejected", + r"(?i)denied", + r"(?i)blacklisted", + r"(?i)prohibited", + r"(?i)refused", + r"(?i)550 5\.7\.", + ]), + }, + BouncePatterns { + bounce_type: BounceType::SpamRelated, + patterns: compile_patterns(&[ + r"(?i)spam", + r"(?i)bulk mail", + r"(?i)content rejected", + r"(?i)message rejected", + r"(?i)550 5\.7\.1", + ]), + }, + BouncePatterns { + bounce_type: BounceType::ServerUnavailable, + patterns: compile_patterns(&[ + r"(?i)server unavailable", + r"(?i)service unavailable", + r"(?i)try again later", + r"(?i)try later", + r"(?i)451 4\.3\.", + r"(?i)421 4\.3\.", + ]), + }, + BouncePatterns { + bounce_type: BounceType::TemporaryFailure, + patterns: compile_patterns(&[ + r"(?i)temporary failure", + r"(?i)temporary error", + r"(?i)temporary problem", + r"(?i)try again", + r"(?i)451 4\.", + ]), + }, + BouncePatterns { + bounce_type: BounceType::QuotaExceeded, + patterns: compile_patterns(&[ + r"(?i)quota temporarily exceeded", + r"(?i)mailbox temporarily full", + r"(?i)452 4\.2\.2", + ]), + }, + BouncePatterns { + bounce_type: BounceType::NetworkError, + patterns: compile_patterns(&[ + r"(?i)network error", + r"(?i)connection error", + r"(?i)connection timed out", + r"(?i)routing error", + r"(?i)421 4\.4\.", + ]), + }, + BouncePatterns { + bounce_type: BounceType::Timeout, + patterns: compile_patterns(&[ + r"(?i)timed out", + r"(?i)timeout", + r"(?i)450 4\.4\.2", + ]), + }, + BouncePatterns { + bounce_type: BounceType::AutoResponse, + patterns: compile_patterns(&[ + r"(?i)auto[- ]reply", + r"(?i)auto[- ]response", + r"(?i)vacation", + r"(?i)out of office", + r"(?i)away from office", + r"(?i)on vacation", + r"(?i)automatic reply", + ]), + }, + BouncePatterns { + bounce_type: BounceType::ChallengeResponse, + patterns: compile_patterns(&[ + r"(?i)challenge[- ]response", + r"(?i)verify your email", + r"(?i)confirm your email", + r"(?i)email verification", + ]), + }, + ] +}); + +/// Regex for detecting bounce email subjects. +static BOUNCE_SUBJECT_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)mail delivery|delivery (?:failed|status|notification)|failure notice|returned mail|undeliverable|delivery problem") + .expect("invalid bounce subject regex") +}); + +/// Regex for extracting recipient from bounce messages. +static BOUNCE_RECIPIENT_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)(?:failed recipient|to[:=]\s*|recipient:|delivery failed:)\s*?") + .expect("invalid bounce recipient regex") +}); + +/// Regex for extracting diagnostic code. +static DIAGNOSTIC_CODE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)diagnostic(?:-|\s+)code:\s*(.+)") + .expect("invalid diagnostic code regex") +}); + +/// Regex for extracting status code. +static STATUS_CODE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)status(?:-|\s+)code:\s*([0-9.]+)") + .expect("invalid status code regex") +}); + +/// Regex for DSN original-recipient. +static DSN_ORIGINAL_RECIPIENT_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)original-recipient:.*?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})") + .expect("invalid DSN original-recipient regex") +}); + +/// Regex for DSN final-recipient. +static DSN_FINAL_RECIPIENT_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)final-recipient:.*?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})") + .expect("invalid DSN final-recipient regex") +}); + +fn compile_patterns(patterns: &[&str]) -> Vec { + patterns + .iter() + .map(|p| Regex::new(p).expect("invalid bounce pattern regex")) + .collect() +} + +/// Detect bounce type from an SMTP response, diagnostic code, or status code. +pub fn detect_bounce_type( + smtp_response: Option<&str>, + diagnostic_code: Option<&str>, + status_code: Option<&str>, +) -> BounceDetection { + // Check all text sources against patterns + let texts: Vec<&str> = [smtp_response, diagnostic_code, status_code] + .into_iter() + .flatten() + .collect(); + + for bp in BOUNCE_PATTERNS.iter() { + for text in &texts { + for pattern in &bp.patterns { + if pattern.is_match(text) { + return BounceDetection { + bounce_type: bp.bounce_type, + category: bp.bounce_type.category(), + }; + } + } + } + } + + // Fallback: parse DSN status code (class.subject.detail) + if let Some(code) = status_code { + if let Some(detection) = parse_dsn_status(code) { + return detection; + } + } + + // Try to find DSN code in SMTP response + if let Some(resp) = smtp_response { + if let Some(code) = STATUS_CODE_RE.captures(resp).and_then(|c| c.get(1)) { + if let Some(detection) = parse_dsn_status(code.as_str()) { + return detection; + } + } + } + + BounceDetection { + bounce_type: BounceType::Unknown, + category: BounceCategory::Unknown, + } +} + +/// Parse a DSN enhanced status code like "5.1.1" or "4.2.2". +fn parse_dsn_status(code: &str) -> Option { + let parts: Vec<&str> = code.split('.').collect(); + if parts.len() < 2 { + return None; + } + + let class: u8 = parts[0].parse().ok()?; + let subject: u8 = parts[1].parse().ok()?; + + let bounce_type = match (class, subject) { + (5, 1) => BounceType::InvalidRecipient, + (5, 2) => BounceType::MailboxFull, + (5, 7) => BounceType::Blocked, + (5, _) => BounceType::PolicyRelated, + (4, 2) => BounceType::QuotaExceeded, + (4, 3) => BounceType::ServerUnavailable, + (4, 4) => BounceType::NetworkError, + (4, _) => BounceType::TemporaryFailure, + _ => return None, + }; + + Some(BounceDetection { + category: bounce_type.category(), + bounce_type, + }) +} + +/// Check if a subject line looks like a bounce notification. +pub fn is_bounce_subject(subject: &str) -> bool { + BOUNCE_SUBJECT_RE.is_match(subject) +} + +/// Extract the bounced recipient email from a bounce message body. +pub fn extract_bounce_recipient(body: &str) -> Option { + BOUNCE_RECIPIENT_RE + .captures(body) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()) + .or_else(|| { + DSN_FINAL_RECIPIENT_RE + .captures(body) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()) + }) + .or_else(|| { + DSN_ORIGINAL_RECIPIENT_RE + .captures(body) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()) + }) +} + +/// Extract the diagnostic code from a bounce message body. +pub fn extract_diagnostic_code(body: &str) -> Option { + DIAGNOSTIC_CODE_RE + .captures(body) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().trim().to_string()) +} + +/// Extract the status code from a bounce message body. +pub fn extract_status_code(body: &str) -> Option { + STATUS_CODE_RE + .captures(body) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().trim().to_string()) +} + +/// Calculate retry delay using exponential backoff. +/// +/// * `retry_count` - Number of retries so far (0-based) +/// * `initial_delay_ms` - Initial delay in milliseconds (default 15 min = 900_000) +/// * `max_delay_ms` - Maximum delay in milliseconds (default 24h = 86_400_000) +/// * `backoff_factor` - Multiplier per retry (default 2.0) +pub fn retry_delay_ms( + retry_count: u32, + initial_delay_ms: u64, + max_delay_ms: u64, + backoff_factor: f64, +) -> u64 { + let delay = (initial_delay_ms as f64) * backoff_factor.powi(retry_count as i32); + (delay as u64).min(max_delay_ms) +} + +/// Default retry delay with standard parameters. +pub fn default_retry_delay_ms(retry_count: u32) -> u64 { + retry_delay_ms( + retry_count, + 15 * 60 * 1000, // 15 minutes + 24 * 60 * 60 * 1000, // 24 hours + 2.0, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_invalid_recipient() { + let result = detect_bounce_type(Some("550 5.1.1 User unknown"), None, None); + assert_eq!(result.bounce_type, BounceType::InvalidRecipient); + assert_eq!(result.category, BounceCategory::Hard); + } + + #[test] + fn test_detect_mailbox_full() { + let result = detect_bounce_type(Some("552 5.2.2 Mailbox full"), None, None); + assert_eq!(result.bounce_type, BounceType::MailboxFull); + assert_eq!(result.category, BounceCategory::Hard); + } + + #[test] + fn test_detect_temporary_failure() { + let result = detect_bounce_type(Some("451 4.3.0 Try again later"), None, None); + assert_eq!(result.bounce_type, BounceType::ServerUnavailable); + assert_eq!(result.category, BounceCategory::Soft); + } + + #[test] + fn test_detect_auto_response() { + let result = detect_bounce_type(Some("Auto-reply: Out of office"), None, None); + assert_eq!(result.bounce_type, BounceType::AutoResponse); + assert_eq!(result.category, BounceCategory::AutoResponse); + } + + #[test] + fn test_detect_from_dsn_status() { + let result = detect_bounce_type(None, None, Some("5.1.1")); + assert_eq!(result.bounce_type, BounceType::InvalidRecipient); + + let result = detect_bounce_type(None, None, Some("4.4.1")); + assert_eq!(result.bounce_type, BounceType::NetworkError); + } + + #[test] + fn test_detect_unknown() { + let result = detect_bounce_type(Some("Something weird happened"), None, None); + assert_eq!(result.bounce_type, BounceType::Unknown); + } + + #[test] + fn test_is_bounce_subject() { + assert!(is_bounce_subject("Mail Delivery Failure")); + assert!(is_bounce_subject("Delivery Status Notification")); + assert!(is_bounce_subject("Returned mail: see transcript for details")); + assert!(is_bounce_subject("Undeliverable: Your message")); + assert!(!is_bounce_subject("Hello World")); + assert!(!is_bounce_subject("Meeting tomorrow")); + } + + #[test] + fn test_extract_bounce_recipient() { + let body = "Delivery to the following recipient failed:\n recipient: user@example.com"; + assert_eq!( + extract_bounce_recipient(body), + Some("user@example.com".to_string()) + ); + + let body = "Final-Recipient: rfc822;bounce@test.org"; + assert_eq!( + extract_bounce_recipient(body), + Some("bounce@test.org".to_string()) + ); + } + + #[test] + fn test_retry_delay() { + assert_eq!(default_retry_delay_ms(0), 900_000); // 15 min + assert_eq!(default_retry_delay_ms(1), 1_800_000); // 30 min + assert_eq!(default_retry_delay_ms(2), 3_600_000); // 1 hour + + // Capped at 24h + assert_eq!(default_retry_delay_ms(20), 86_400_000); + } +} diff --git a/rust/crates/mailer-core/src/email.rs b/rust/crates/mailer-core/src/email.rs new file mode 100644 index 0000000..a922924 --- /dev/null +++ b/rust/crates/mailer-core/src/email.rs @@ -0,0 +1,411 @@ +use std::collections::HashMap; +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use crate::error::{MailerError, Result}; +use crate::mime::build_rfc822; +use crate::validation::is_valid_email_format; + +/// Email priority level. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Priority { + High, + Normal, + Low, +} + +impl Default for Priority { + fn default() -> Self { + Priority::Normal + } +} + +impl fmt::Display for Priority { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Priority::High => write!(f, "high"), + Priority::Normal => write!(f, "normal"), + Priority::Low => write!(f, "low"), + } + } +} + +/// A parsed email address with local part and domain. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct EmailAddress { + pub local: String, + pub domain: String, +} + +impl EmailAddress { + /// Parse an email address string like "user@example.com" or "Name ". + pub fn parse(input: &str) -> Result { + let addr = extract_email_address(input) + .ok_or_else(|| MailerError::InvalidEmail(input.to_string()))?; + + let parts: Vec<&str> = addr.splitn(2, '@').collect(); + if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { + return Err(MailerError::InvalidEmail(input.to_string())); + } + + Ok(EmailAddress { + local: parts[0].to_string(), + domain: parts[1].to_lowercase(), + }) + } + + /// Return the full address as "local@domain". + pub fn address(&self) -> String { + format!("{}@{}", self.local, self.domain) + } +} + +impl fmt::Display for EmailAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}@{}", self.local, self.domain) + } +} + +/// Extract the bare email address from a string that may contain display names or angle brackets. +/// Handles formats like: +/// - "user@example.com" +/// - "" +/// - "John Doe " +pub fn extract_email_address(input: &str) -> Option { + let trimmed = input.trim(); + + // Handle null sender + if trimmed == "<>" { + return None; + } + + // Try to extract from angle brackets + if let Some(start) = trimmed.find('<') { + if let Some(end) = trimmed.find('>') { + if end > start { + let addr = trimmed[start + 1..end].trim(); + if !addr.is_empty() { + return Some(addr.to_string()); + } + } + } + } + + // No angle brackets — treat entire string as address if it contains @ + if trimmed.contains('@') { + return Some(trimmed.to_string()); + } + + None +} + +/// An email attachment. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Attachment { + pub filename: String, + #[serde(with = "serde_bytes_base64")] + pub content: Vec, + pub content_type: String, + pub content_id: Option, +} + +/// Serde helper for base64-encoding Vec in JSON. +mod serde_bytes_base64 { + use base64::engine::general_purpose::STANDARD; + use base64::Engine; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(data: &[u8], serializer: S) -> Result { + serializer.serialize_str(&STANDARD.encode(data)) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + let s = String::deserialize(deserializer)?; + STANDARD.decode(s).map_err(serde::de::Error::custom) + } +} + +/// A complete email message. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Email { + pub from: String, + pub to: Vec, + pub cc: Vec, + pub bcc: Vec, + pub subject: String, + pub text: String, + pub html: Option, + pub attachments: Vec, + pub headers: HashMap, + pub priority: Priority, + pub might_be_spam: bool, + message_id: Option, + envelope_from: Option, +} + +impl Email { + /// Create a new email with the minimum required fields. + pub fn new(from: &str, subject: &str, text: &str) -> Self { + Email { + from: from.to_string(), + to: Vec::new(), + cc: Vec::new(), + bcc: Vec::new(), + subject: subject.to_string(), + text: text.to_string(), + html: None, + attachments: Vec::new(), + headers: HashMap::new(), + priority: Priority::Normal, + might_be_spam: false, + message_id: None, + envelope_from: None, + } + } + + /// Add a To recipient. + pub fn add_to(&mut self, email: &str) -> &mut Self { + self.to.push(email.to_string()); + self + } + + /// Add a CC recipient. + pub fn add_cc(&mut self, email: &str) -> &mut Self { + self.cc.push(email.to_string()); + self + } + + /// Add a BCC recipient. + pub fn add_bcc(&mut self, email: &str) -> &mut Self { + self.bcc.push(email.to_string()); + self + } + + /// Set the HTML body. + pub fn set_html(&mut self, html: &str) -> &mut Self { + self.html = Some(html.to_string()); + self + } + + /// Add an attachment. + pub fn add_attachment(&mut self, attachment: Attachment) -> &mut Self { + self.attachments.push(attachment); + self + } + + /// Add a custom header. + pub fn add_header(&mut self, name: &str, value: &str) -> &mut Self { + self.headers.insert(name.to_string(), value.to_string()); + self + } + + /// Set email priority. + pub fn set_priority(&mut self, priority: Priority) -> &mut Self { + self.priority = priority; + self + } + + /// Get the sender domain. + pub fn from_domain(&self) -> Option { + EmailAddress::parse(&self.from) + .ok() + .map(|addr| addr.domain) + } + + /// Get the sender address (bare email, no display name). + pub fn from_address(&self) -> Option { + extract_email_address(&self.from) + } + + /// Get all recipients (to + cc + bcc), deduplicated. + pub fn all_recipients(&self) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut result = Vec::new(); + for addr in self.to.iter().chain(self.cc.iter()).chain(self.bcc.iter()) { + let lower = addr.to_lowercase(); + if seen.insert(lower) { + result.push(addr.clone()); + } + } + result + } + + /// Get the primary (first To) recipient. + pub fn primary_recipient(&self) -> Option<&str> { + self.to.first().map(|s| s.as_str()) + } + + /// Check whether this email has attachments. + pub fn has_attachments(&self) -> bool { + !self.attachments.is_empty() + } + + /// Get total attachment size in bytes. + pub fn attachments_size(&self) -> usize { + self.attachments.iter().map(|a| a.content.len()).sum() + } + + /// Get or generate a Message-ID. + pub fn message_id(&self) -> String { + if let Some(ref id) = self.message_id { + return id.clone(); + } + let domain = self.from_domain().unwrap_or_else(|| "localhost".to_string()); + let unique = uuid::Uuid::new_v4(); + format!("<{}.{}@{}>", chrono_millis(), unique, domain) + } + + /// Set an explicit Message-ID. + pub fn set_message_id(&mut self, id: &str) -> &mut Self { + self.message_id = Some(id.to_string()); + self + } + + /// Get the envelope-from (MAIL FROM), falls back to the From header address. + pub fn envelope_from(&self) -> Option { + self.envelope_from + .clone() + .or_else(|| self.from_address()) + } + + /// Set the envelope-from address. + pub fn set_envelope_from(&mut self, addr: &str) -> &mut Self { + self.envelope_from = Some(addr.to_string()); + self + } + + /// Sanitize a string by removing CR/LF (header injection prevention). + pub fn sanitize_string(input: &str) -> String { + input.replace(['\r', '\n'], " ") + } + + /// Validate all addresses in this email. + pub fn validate_addresses(&self) -> Vec { + let mut errors = Vec::new(); + + if !is_valid_email_format(&self.from) { + if extract_email_address(&self.from) + .map(|a| !is_valid_email_format(&a)) + .unwrap_or(true) + { + errors.push(format!("Invalid from address: {}", self.from)); + } + } + + for addr in &self.to { + if !is_valid_email_format(addr) { + if extract_email_address(addr) + .map(|a| !is_valid_email_format(&a)) + .unwrap_or(true) + { + errors.push(format!("Invalid to address: {}", addr)); + } + } + } + + for addr in &self.cc { + if !is_valid_email_format(addr) { + if extract_email_address(addr) + .map(|a| !is_valid_email_format(&a)) + .unwrap_or(true) + { + errors.push(format!("Invalid cc address: {}", addr)); + } + } + } + + for addr in &self.bcc { + if !is_valid_email_format(addr) { + if extract_email_address(addr) + .map(|a| !is_valid_email_format(&a)) + .unwrap_or(true) + { + errors.push(format!("Invalid bcc address: {}", addr)); + } + } + } + + errors + } + + /// Convert the email to RFC 5322 format. + pub fn to_rfc822(&self) -> Result { + build_rfc822(self) + } +} + +/// Simple epoch millis using std::time (no chrono dependency needed). +fn chrono_millis() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_email_address_parse() { + let addr = EmailAddress::parse("user@example.com").unwrap(); + assert_eq!(addr.local, "user"); + assert_eq!(addr.domain, "example.com"); + + let addr = EmailAddress::parse("John Doe ").unwrap(); + assert_eq!(addr.local, "john"); + assert_eq!(addr.domain, "example.com"); + + let addr = EmailAddress::parse("").unwrap(); + assert_eq!(addr.local, "admin"); + assert_eq!(addr.domain, "test.org"); + } + + #[test] + fn test_extract_email_address() { + assert_eq!( + extract_email_address("John "), + Some("john@example.com".to_string()) + ); + assert_eq!( + extract_email_address("user@example.com"), + Some("user@example.com".to_string()) + ); + assert_eq!(extract_email_address("<>"), None); + assert_eq!(extract_email_address("no-at-sign"), None); + } + + #[test] + fn test_email_new() { + let mut email = Email::new("sender@example.com", "Test", "Hello"); + email.add_to("recipient@example.com"); + assert_eq!(email.from_domain(), Some("example.com".to_string())); + assert_eq!(email.all_recipients().len(), 1); + } + + #[test] + fn test_all_recipients_dedup() { + let mut email = Email::new("sender@example.com", "Test", "Hello"); + email.add_to("a@example.com"); + email.add_cc("a@example.com"); // duplicate (case-insensitive) + email.add_bcc("b@example.com"); + assert_eq!(email.all_recipients().len(), 2); + } + + #[test] + fn test_sanitize_string() { + assert_eq!(Email::sanitize_string("hello\r\nworld"), "hello world"); + assert_eq!(Email::sanitize_string("normal"), "normal"); + } + + #[test] + fn test_message_id_generation() { + let email = Email::new("sender@example.com", "Test", "Hello"); + let mid = email.message_id(); + assert!(mid.starts_with('<')); + assert!(mid.ends_with('>')); + assert!(mid.contains("@example.com")); + } +} diff --git a/rust/crates/mailer-core/src/error.rs b/rust/crates/mailer-core/src/error.rs new file mode 100644 index 0000000..19111ce --- /dev/null +++ b/rust/crates/mailer-core/src/error.rs @@ -0,0 +1,31 @@ +use thiserror::Error; + +/// Core error types for the mailer system. +#[derive(Debug, Error)] +pub enum MailerError { + #[error("invalid email address: {0}")] + InvalidEmail(String), + + #[error("invalid email format: {0}")] + InvalidFormat(String), + + #[error("missing required field: {0}")] + MissingField(String), + + #[error("MIME encoding error: {0}")] + MimeError(String), + + #[error("validation error: {0}")] + ValidationError(String), + + #[error("parse error: {0}")] + ParseError(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("regex error: {0}")] + Regex(#[from] regex::Error), +} + +pub type Result = std::result::Result; diff --git a/rust/crates/mailer-core/src/lib.rs b/rust/crates/mailer-core/src/lib.rs index 8007609..d50ae54 100644 --- a/rust/crates/mailer-core/src/lib.rs +++ b/rust/crates/mailer-core/src/lib.rs @@ -1,9 +1,25 @@ //! mailer-core: Email model, validation, and RFC 5322 primitives. +pub mod bounce; +pub mod email; +pub mod error; +pub mod mime; +pub mod validation; + +// Re-exports for convenience +pub use bounce::{ + detect_bounce_type, extract_bounce_recipient, is_bounce_subject, BounceCategory, + BounceDetection, BounceType, +}; +pub use email::{extract_email_address, Attachment, Email, EmailAddress, Priority}; +pub use error::{MailerError, Result}; +pub use mime::build_rfc822; +pub use validation::{is_valid_email_format, validate_email, EmailValidationResult}; + /// Re-export mailparse for MIME parsing. pub use mailparse; -/// Placeholder for email address validation and data types. +/// Crate version. pub fn version() -> &'static str { env!("CARGO_PKG_VERSION") } diff --git a/rust/crates/mailer-core/src/mime.rs b/rust/crates/mailer-core/src/mime.rs new file mode 100644 index 0000000..15b5900 --- /dev/null +++ b/rust/crates/mailer-core/src/mime.rs @@ -0,0 +1,377 @@ +use base64::engine::general_purpose::STANDARD; +use base64::Engine; + +use crate::email::Email; +use crate::error::Result; + +/// Generate a MIME boundary string. +fn generate_boundary() -> String { + let id = uuid::Uuid::new_v4(); + format!("----=_Part_{}", id.as_simple()) +} + +/// Build an RFC 5322 compliant email message from an Email struct. +pub fn build_rfc822(email: &Email) -> Result { + let mut output = String::with_capacity(4096); + let message_id = email.message_id(); + + // Required headers + output.push_str(&format!( + "From: {}\r\n", + Email::sanitize_string(&email.from) + )); + + if !email.to.is_empty() { + output.push_str(&format!( + "To: {}\r\n", + email + .to + .iter() + .map(|a| Email::sanitize_string(a)) + .collect::>() + .join(", ") + )); + } + + if !email.cc.is_empty() { + output.push_str(&format!( + "Cc: {}\r\n", + email + .cc + .iter() + .map(|a| Email::sanitize_string(a)) + .collect::>() + .join(", ") + )); + } + + output.push_str(&format!( + "Subject: {}\r\n", + Email::sanitize_string(&email.subject) + )); + output.push_str(&format!("Message-ID: {}\r\n", message_id)); + output.push_str(&format!("Date: {}\r\n", rfc2822_now())); + output.push_str("MIME-Version: 1.0\r\n"); + + // Priority headers + match email.priority { + crate::email::Priority::High => { + output.push_str("X-Priority: 1\r\n"); + output.push_str("Importance: high\r\n"); + } + crate::email::Priority::Low => { + output.push_str("X-Priority: 5\r\n"); + output.push_str("Importance: low\r\n"); + } + crate::email::Priority::Normal => {} + } + + // Custom headers + for (name, value) in &email.headers { + output.push_str(&format!( + "{}: {}\r\n", + Email::sanitize_string(name), + Email::sanitize_string(value) + )); + } + + let has_html = email.html.is_some(); + let has_attachments = !email.attachments.is_empty(); + + match (has_html, has_attachments) { + (false, false) => { + // Plain text only + output.push_str("Content-Type: text/plain; charset=UTF-8\r\n"); + output.push_str("Content-Transfer-Encoding: quoted-printable\r\n"); + output.push_str("\r\n"); + output.push_str("ed_printable_encode(&email.text)); + } + (true, false) => { + // multipart/alternative (text + html) + let boundary = generate_boundary(); + output.push_str(&format!( + "Content-Type: multipart/alternative; boundary=\"{}\"\r\n", + boundary + )); + output.push_str("\r\n"); + + // Text part + output.push_str(&format!("--{}\r\n", boundary)); + output.push_str("Content-Type: text/plain; charset=UTF-8\r\n"); + output.push_str("Content-Transfer-Encoding: quoted-printable\r\n"); + output.push_str("\r\n"); + output.push_str("ed_printable_encode(&email.text)); + output.push_str("\r\n"); + + // HTML part + output.push_str(&format!("--{}\r\n", boundary)); + output.push_str("Content-Type: text/html; charset=UTF-8\r\n"); + output.push_str("Content-Transfer-Encoding: quoted-printable\r\n"); + output.push_str("\r\n"); + output.push_str("ed_printable_encode(email.html.as_deref().unwrap())); + output.push_str("\r\n"); + + output.push_str(&format!("--{}--\r\n", boundary)); + } + (_, true) => { + // multipart/mixed with optional multipart/alternative inside + let mixed_boundary = generate_boundary(); + output.push_str(&format!( + "Content-Type: multipart/mixed; boundary=\"{}\"\r\n", + mixed_boundary + )); + output.push_str("\r\n"); + + if has_html { + // multipart/alternative for text+html + let alt_boundary = generate_boundary(); + output.push_str(&format!("--{}\r\n", mixed_boundary)); + output.push_str(&format!( + "Content-Type: multipart/alternative; boundary=\"{}\"\r\n", + alt_boundary + )); + output.push_str("\r\n"); + + // Text part + output.push_str(&format!("--{}\r\n", alt_boundary)); + output.push_str("Content-Type: text/plain; charset=UTF-8\r\n"); + output.push_str("Content-Transfer-Encoding: quoted-printable\r\n"); + output.push_str("\r\n"); + output.push_str("ed_printable_encode(&email.text)); + output.push_str("\r\n"); + + // HTML part + output.push_str(&format!("--{}\r\n", alt_boundary)); + output.push_str("Content-Type: text/html; charset=UTF-8\r\n"); + output.push_str("Content-Transfer-Encoding: quoted-printable\r\n"); + output.push_str("\r\n"); + output.push_str("ed_printable_encode(email.html.as_deref().unwrap())); + output.push_str("\r\n"); + + output.push_str(&format!("--{}--\r\n", alt_boundary)); + } else { + // Plain text only + output.push_str(&format!("--{}\r\n", mixed_boundary)); + output.push_str("Content-Type: text/plain; charset=UTF-8\r\n"); + output.push_str("Content-Transfer-Encoding: quoted-printable\r\n"); + output.push_str("\r\n"); + output.push_str("ed_printable_encode(&email.text)); + output.push_str("\r\n"); + } + + // Attachments + for attachment in &email.attachments { + output.push_str(&format!("--{}\r\n", mixed_boundary)); + output.push_str(&format!( + "Content-Type: {}; name=\"{}\"\r\n", + attachment.content_type, attachment.filename + )); + output.push_str("Content-Transfer-Encoding: base64\r\n"); + + if let Some(ref cid) = attachment.content_id { + output.push_str(&format!("Content-ID: <{}>\r\n", cid)); + output.push_str("Content-Disposition: inline\r\n"); + } else { + output.push_str(&format!( + "Content-Disposition: attachment; filename=\"{}\"\r\n", + attachment.filename + )); + } + + output.push_str("\r\n"); + output.push_str(&base64_encode_wrapped(&attachment.content)); + output.push_str("\r\n"); + } + + output.push_str(&format!("--{}--\r\n", mixed_boundary)); + } + } + + Ok(output) +} + +/// Encode a string as quoted-printable (RFC 2045). +fn quoted_printable_encode(input: &str) -> String { + let mut output = String::with_capacity(input.len() * 2); + let mut line_len = 0; + + for byte in input.bytes() { + let encoded = match byte { + // Printable ASCII that doesn't need encoding (except =) + b' '..=b'<' | b'>'..=b'~' => { + line_len += 1; + (byte as char).to_string() + } + b'\t' => { + line_len += 1; + "\t".to_string() + } + b'\r' => continue, // handled with \n + b'\n' => { + line_len = 0; + "\r\n".to_string() + } + _ => { + line_len += 3; + format!("={:02X}", byte) + } + }; + + // Soft line break at 76 characters + if line_len > 75 && byte != b'\n' { + output.push_str("=\r\n"); + line_len = encoded.len(); + } + + output.push_str(&encoded); + } + + output +} + +/// Base64-encode binary data with 76-character line wrapping. +fn base64_encode_wrapped(data: &[u8]) -> String { + let encoded = STANDARD.encode(data); + let mut output = String::with_capacity(encoded.len() + encoded.len() / 76 * 2); + for (i, ch) in encoded.chars().enumerate() { + if i > 0 && i % 76 == 0 { + output.push_str("\r\n"); + } + output.push(ch); + } + output +} + +/// Generate current date in RFC 2822 format (e.g., "Tue, 10 Feb 2026 12:00:00 +0000"). +fn rfc2822_now() -> String { + use std::time::SystemTime; + + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + // Simple UTC formatting without chrono dependency + let days = now / 86400; + let time_of_day = now % 86400; + let hours = time_of_day / 3600; + let minutes = (time_of_day % 3600) / 60; + let seconds = time_of_day % 60; + + // Calculate year/month/day from days since epoch + let (year, month, day) = days_to_ymd(days); + + let day_of_week = ((days + 4) % 7) as usize; // Jan 1 1970 = Thursday (4) + let dow = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + let mon = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + + format!( + "{}, {:02} {} {:04} {:02}:{:02}:{:02} +0000", + dow[day_of_week], + day, + mon[(month - 1) as usize], + year, + hours, + minutes, + seconds + ) +} + +/// Convert days since Unix epoch to (year, month, day). +fn days_to_ymd(days: u64) -> (u64, u64, u64) { + // Algorithm from https://howardhinnant.github.io/date_algorithms.html + let z = days + 719468; + let era = z / 146097; + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::email::Email; + + #[test] + fn test_plain_text_email() { + let mut email = Email::new("sender@example.com", "Test Subject", "Hello World"); + email.add_to("recipient@example.com"); + email.set_message_id(""); + + let rfc822 = build_rfc822(&email).unwrap(); + assert!(rfc822.contains("From: sender@example.com")); + assert!(rfc822.contains("To: recipient@example.com")); + assert!(rfc822.contains("Subject: Test Subject")); + assert!(rfc822.contains("Content-Type: text/plain; charset=UTF-8")); + assert!(rfc822.contains("Hello World")); + } + + #[test] + fn test_html_email() { + let mut email = Email::new("sender@example.com", "HTML Test", "Plain text"); + email.add_to("recipient@example.com"); + email.set_html("

HTML content

"); + email.set_message_id(""); + + let rfc822 = build_rfc822(&email).unwrap(); + assert!(rfc822.contains("multipart/alternative")); + assert!(rfc822.contains("text/plain")); + assert!(rfc822.contains("text/html")); + assert!(rfc822.contains("Plain text")); + assert!(rfc822.contains("HTML content")); + } + + #[test] + fn test_email_with_attachment() { + let mut email = Email::new("sender@example.com", "Attachment Test", "See attached"); + email.add_to("recipient@example.com"); + email.set_message_id(""); + email.add_attachment(crate::email::Attachment { + filename: "test.txt".to_string(), + content: b"Hello attachment".to_vec(), + content_type: "text/plain".to_string(), + content_id: None, + }); + + let rfc822 = build_rfc822(&email).unwrap(); + assert!(rfc822.contains("multipart/mixed")); + assert!(rfc822.contains("Content-Disposition: attachment")); + assert!(rfc822.contains("test.txt")); + } + + #[test] + fn test_quoted_printable() { + let input = "Hello = World"; + let encoded = quoted_printable_encode(input); + assert!(encoded.contains("=3D")); // = is encoded + + let input = "Plain ASCII text"; + let encoded = quoted_printable_encode(input); + assert_eq!(encoded, "Plain ASCII text"); + } + + #[test] + fn test_base64_wrapped() { + let data = vec![0u8; 100]; + let encoded = base64_encode_wrapped(&data); + for line in encoded.split("\r\n") { + assert!(line.len() <= 76); + } + } + + #[test] + fn test_rfc2822_date() { + let date = rfc2822_now(); + // Should match pattern like "Tue, 10 Feb 2026 12:00:00 +0000" + assert!(date.contains("+0000")); + assert!(date.len() > 20); + } +} diff --git a/rust/crates/mailer-core/src/validation.rs b/rust/crates/mailer-core/src/validation.rs new file mode 100644 index 0000000..ff8cb64 --- /dev/null +++ b/rust/crates/mailer-core/src/validation.rs @@ -0,0 +1,178 @@ +use regex::Regex; +use std::sync::LazyLock; + +/// Basic email format regex — covers the vast majority of valid email addresses. +/// Does NOT attempt to match the full RFC 5321 grammar (which is impractical via regex). +static EMAIL_REGEX: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?i)^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$", + ) + .expect("invalid email regex") +}); + +/// Check whether an email address has valid syntax. +pub fn is_valid_email_format(email: &str) -> bool { + let email = email.trim(); + if email.is_empty() || email.len() > 254 { + return false; + } + + let parts: Vec<&str> = email.rsplitn(2, '@').collect(); + if parts.len() != 2 { + return false; + } + let local = parts[1]; + let domain = parts[0]; + + // Local part max 64 chars + if local.is_empty() || local.len() > 64 { + return false; + } + + // Domain must have at least one dot (TLD only not valid for email) + if !domain.contains('.') { + return false; + } + + EMAIL_REGEX.is_match(email) +} + +/// Email validation result with scoring. +#[derive(Debug, Clone)] +pub struct EmailValidationResult { + pub is_valid: bool, + pub format_valid: bool, + pub score: f64, + pub error_message: Option, +} + +/// Validate an email address (synchronous, format-only). +/// DNS-based validation (MX records, disposable domains) would require async and is +/// intended for the N-API bridge layer where the TypeScript side already has DNS access. +pub fn validate_email(email: &str) -> EmailValidationResult { + let format_valid = is_valid_email_format(email); + + if !format_valid { + return EmailValidationResult { + is_valid: false, + format_valid: false, + score: 0.0, + error_message: Some(format!("Invalid email format: {}", email)), + }; + } + + // Role account detection (weight 0.1 penalty) + let local = email.split('@').next().unwrap_or(""); + let is_role = is_role_account(local); + + // Score: format (0.4) + assumed-mx (0.3) + assumed-not-disposable (0.2) + role (0.1) + let mut score = 0.4 + 0.3 + 0.2; // format + mx + not-disposable + if !is_role { + score += 0.1; + } + + EmailValidationResult { + is_valid: score >= 0.7, + format_valid: true, + score, + error_message: None, + } +} + +/// Check if a local part is a common role account. +fn is_role_account(local: &str) -> bool { + const ROLE_ACCOUNTS: &[&str] = &[ + "abuse", + "admin", + "administrator", + "billing", + "compliance", + "devnull", + "dns", + "ftp", + "hostmaster", + "info", + "inoc", + "ispfeedback", + "ispsupport", + "list", + "list-request", + "maildaemon", + "mailer-daemon", + "mailerdaemon", + "marketing", + "noc", + "no-reply", + "noreply", + "null", + "phish", + "phishing", + "postmaster", + "privacy", + "registrar", + "root", + "sales", + "security", + "spam", + "support", + "sysadmin", + "tech", + "undisclosed-recipients", + "unsubscribe", + "usenet", + "uucp", + "webmaster", + "www", + ]; + let lower = local.to_lowercase(); + ROLE_ACCOUNTS.contains(&lower.as_str()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_emails() { + assert!(is_valid_email_format("user@example.com")); + assert!(is_valid_email_format("first.last@example.com")); + assert!(is_valid_email_format("user+tag@example.com")); + assert!(is_valid_email_format("user@sub.domain.example.com")); + } + + #[test] + fn test_invalid_emails() { + assert!(!is_valid_email_format("")); + assert!(!is_valid_email_format("@")); + assert!(!is_valid_email_format("user@")); + assert!(!is_valid_email_format("@domain.com")); + assert!(!is_valid_email_format("user@domain")); // no TLD + assert!(!is_valid_email_format("user @domain.com")); // space + assert!(!is_valid_email_format("user@.com")); // leading dot + } + + #[test] + fn test_validate_email_scoring() { + let result = validate_email("user@example.com"); + assert!(result.is_valid); + assert!(result.score >= 0.9); + + let result = validate_email("postmaster@example.com"); + assert!(result.is_valid); + assert!(result.score >= 0.7); + assert!(result.score < 1.0); // role account penalty + + let result = validate_email("not-an-email"); + assert!(!result.is_valid); + assert_eq!(result.score, 0.0); + } + + #[test] + fn test_role_accounts() { + assert!(is_role_account("postmaster")); + assert!(is_role_account("abuse")); + assert!(is_role_account("noreply")); + assert!(!is_role_account("john")); + assert!(!is_role_account("alice")); + } +} diff --git a/rust/crates/mailer-security/src/dkim.rs b/rust/crates/mailer-security/src/dkim.rs new file mode 100644 index 0000000..6399a4e --- /dev/null +++ b/rust/crates/mailer-security/src/dkim.rs @@ -0,0 +1,148 @@ +use mail_auth::common::crypto::{RsaKey, Sha256}; +use mail_auth::common::headers::HeaderWriter; +use mail_auth::dkim::{Canonicalization, DkimSigner}; +use mail_auth::{AuthenticatedMessage, DkimResult, MessageAuthenticator}; +use rustls_pki_types::{PrivateKeyDer, PrivatePkcs1KeyDer, pem::PemObject}; +use serde::{Deserialize, Serialize}; + +use crate::error::{Result, SecurityError}; + +/// Result of DKIM verification. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DkimVerificationResult { + /// Whether the DKIM signature is valid. + pub is_valid: bool, + /// The signing domain (d= tag). + pub domain: Option, + /// The selector (s= tag). + pub selector: Option, + /// Result status: "pass", "fail", "permerror", "temperror", "none". + pub status: String, + /// Human-readable details. + pub details: Option, +} + +/// Verify DKIM signatures on a raw email message. +/// +/// Uses the `mail-auth` crate which performs full RFC 6376 verification +/// including DNS lookups for the public key. +pub async fn verify_dkim( + raw_message: &[u8], + authenticator: &MessageAuthenticator, +) -> Result> { + let message = AuthenticatedMessage::parse(raw_message) + .ok_or_else(|| SecurityError::Parse("Failed to parse email for DKIM verification".into()))?; + + let dkim_outputs = authenticator.verify_dkim(&message).await; + + let mut results = Vec::new(); + + if dkim_outputs.is_empty() { + results.push(DkimVerificationResult { + is_valid: false, + domain: None, + selector: None, + status: "none".to_string(), + details: Some("No DKIM signatures found".to_string()), + }); + return Ok(results); + } + + for output in &dkim_outputs { + let (is_valid, status, details) = match output.result() { + DkimResult::Pass => (true, "pass", None), + DkimResult::Neutral(err) => (false, "neutral", Some(err.to_string())), + DkimResult::Fail(err) => (false, "fail", Some(err.to_string())), + DkimResult::PermError(err) => (false, "permerror", Some(err.to_string())), + DkimResult::TempError(err) => (false, "temperror", Some(err.to_string())), + DkimResult::None => (false, "none", None), + }; + + let (domain, selector) = output + .signature() + .map(|sig| (Some(sig.d.clone()), Some(sig.s.clone()))) + .unwrap_or((None, None)); + + results.push(DkimVerificationResult { + is_valid, + domain, + selector, + status: status.to_string(), + details, + }); + } + + Ok(results) +} + +/// Sign a raw email message with DKIM (RSA-SHA256). +/// +/// * `raw_message` - The raw RFC 5322 message bytes +/// * `domain` - The signing domain (d= tag) +/// * `selector` - The DKIM selector (s= tag) +/// * `private_key_pem` - RSA private key in PEM format (PKCS#1 or PKCS#8) +/// +/// Returns the DKIM-Signature header string to prepend to the message. +pub fn sign_dkim( + raw_message: &[u8], + domain: &str, + selector: &str, + private_key_pem: &str, +) -> Result { + // Try PKCS#1 PEM first, then PKCS#8 + let key_der = PrivatePkcs1KeyDer::from_pem_slice(private_key_pem.as_bytes()) + .map(PrivateKeyDer::Pkcs1) + .or_else(|_| { + // Try PKCS#8 + rustls_pki_types::PrivatePkcs8KeyDer::from_pem_slice(private_key_pem.as_bytes()) + .map(PrivateKeyDer::Pkcs8) + }) + .map_err(|e| SecurityError::Key(format!("Failed to parse private key PEM: {}", e)))?; + + let rsa_key = RsaKey::::from_key_der(key_der) + .map_err(|e| SecurityError::Key(format!("Failed to load RSA key: {}", e)))?; + + let signature = DkimSigner::from_key(rsa_key) + .domain(domain) + .selector(selector) + .headers(["From", "To", "Subject", "Date", "Message-ID", "MIME-Version", "Content-Type"]) + .header_canonicalization(Canonicalization::Relaxed) + .body_canonicalization(Canonicalization::Relaxed) + .sign(raw_message) + .map_err(|e| SecurityError::Dkim(format!("DKIM signing failed: {}", e)))?; + + Ok(signature.to_header()) +} + +/// Generate a DKIM DNS TXT record value for a given public key. +/// +/// Returns the value for a TXT record at `{selector}._domainkey.{domain}`. +pub fn dkim_dns_record_value(public_key_pem: &str) -> String { + // Extract the base64 content from PEM + let key_b64: String = public_key_pem + .lines() + .filter(|line| !line.starts_with("-----")) + .collect::>() + .join(""); + + format!("v=DKIM1; h=sha256; k=rsa; p={}", key_b64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dkim_dns_record_value() { + let pem = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBg==\n-----END PUBLIC KEY-----"; + let record = dkim_dns_record_value(pem); + assert!(record.starts_with("v=DKIM1; h=sha256; k=rsa; p=")); + assert!(record.contains("MIIBIjANBg==")); + } + + #[test] + fn test_sign_dkim_invalid_key() { + let result = sign_dkim(b"From: test@example.com\r\n\r\nBody", "example.com", "mta", "not a key"); + assert!(result.is_err()); + } +} diff --git a/rust/crates/mailer-security/src/dmarc.rs b/rust/crates/mailer-security/src/dmarc.rs new file mode 100644 index 0000000..c8f8065 --- /dev/null +++ b/rust/crates/mailer-security/src/dmarc.rs @@ -0,0 +1,127 @@ +use mail_auth::dmarc::verify::DmarcParameters; +use mail_auth::dmarc::Policy; +use mail_auth::{ + AuthenticatedMessage, DkimOutput, DmarcResult as MailAuthDmarcResult, MessageAuthenticator, + SpfOutput, +}; +use serde::{Deserialize, Serialize}; + +use crate::error::{Result, SecurityError}; + +/// DMARC policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DmarcPolicy { + None, + Quarantine, + Reject, +} + +impl From for DmarcPolicy { + fn from(p: Policy) -> Self { + match p { + Policy::None | Policy::Unspecified => DmarcPolicy::None, + Policy::Quarantine => DmarcPolicy::Quarantine, + Policy::Reject => DmarcPolicy::Reject, + } + } +} + +/// DMARC verification result. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DmarcResult { + /// Whether DMARC verification passed overall. + pub passed: bool, + /// The evaluated policy. + pub policy: DmarcPolicy, + /// The domain that was checked. + pub domain: String, + /// DKIM alignment result: "pass", "fail", etc. + pub dkim_result: String, + /// SPF alignment result: "pass", "fail", etc. + pub spf_result: String, + /// Recommended action: "pass", "quarantine", "reject". + pub action: String, + /// Human-readable details. + pub details: Option, +} + +/// Check DMARC for an email, given prior DKIM and SPF results. +/// +/// * `raw_message` - The raw RFC 5322 message bytes +/// * `dkim_output` - DKIM verification results from `verify_dkim` +/// * `spf_output` - SPF verification output from `check_spf` +/// * `mail_from_domain` - The MAIL FROM domain (RFC 5321) +/// * `authenticator` - The MessageAuthenticator for DNS lookups +pub async fn check_dmarc<'x>( + raw_message: &'x [u8], + dkim_output: &'x [DkimOutput<'x>], + spf_output: &'x SpfOutput, + mail_from_domain: &'x str, + authenticator: &MessageAuthenticator, +) -> Result { + let message = AuthenticatedMessage::parse(raw_message) + .ok_or_else(|| SecurityError::Parse("Failed to parse email for DMARC check".into()))?; + + let dmarc_output = authenticator + .verify_dmarc( + DmarcParameters::new(&message, dkim_output, mail_from_domain, spf_output) + .with_domain_suffix_fn(|domain| psl::domain_str(domain).unwrap_or(domain)), + ) + .await; + + let policy = DmarcPolicy::from(dmarc_output.policy()); + let domain = dmarc_output.domain().to_string(); + + let dkim_result_str = dmarc_result_to_string(dmarc_output.dkim_result()); + let spf_result_str = dmarc_result_to_string(dmarc_output.spf_result()); + + let dkim_passed = matches!(dmarc_output.dkim_result(), MailAuthDmarcResult::Pass); + let spf_passed = matches!(dmarc_output.spf_result(), MailAuthDmarcResult::Pass); + let passed = dkim_passed || spf_passed; + + let action = if passed { + "pass".to_string() + } else { + match policy { + DmarcPolicy::None => "pass".to_string(), // p=none means monitor only + DmarcPolicy::Quarantine => "quarantine".to_string(), + DmarcPolicy::Reject => "reject".to_string(), + } + }; + + Ok(DmarcResult { + passed, + policy, + domain, + dkim_result: dkim_result_str, + spf_result: spf_result_str, + action, + details: None, + }) +} + +fn dmarc_result_to_string(result: &MailAuthDmarcResult) -> String { + match result { + MailAuthDmarcResult::Pass => "pass".to_string(), + MailAuthDmarcResult::Fail(err) => format!("fail: {}", err), + MailAuthDmarcResult::TempError(err) => format!("temperror: {}", err), + MailAuthDmarcResult::PermError(err) => format!("permerror: {}", err), + MailAuthDmarcResult::None => "none".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dmarc_policy_from() { + assert_eq!(DmarcPolicy::from(Policy::None), DmarcPolicy::None); + assert_eq!( + DmarcPolicy::from(Policy::Quarantine), + DmarcPolicy::Quarantine + ); + assert_eq!(DmarcPolicy::from(Policy::Reject), DmarcPolicy::Reject); + } +} diff --git a/rust/crates/mailer-security/src/error.rs b/rust/crates/mailer-security/src/error.rs new file mode 100644 index 0000000..b5a7fdc --- /dev/null +++ b/rust/crates/mailer-security/src/error.rs @@ -0,0 +1,31 @@ +use thiserror::Error; + +/// Security-related error types. +#[derive(Debug, Error)] +pub enum SecurityError { + #[error("DKIM error: {0}")] + Dkim(String), + + #[error("SPF error: {0}")] + Spf(String), + + #[error("DMARC error: {0}")] + Dmarc(String), + + #[error("DNS resolution error: {0}")] + Dns(String), + + #[error("key error: {0}")] + Key(String), + + #[error("IP reputation error: {0}")] + IpReputation(String), + + #[error("parse error: {0}")] + Parse(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +pub type Result = std::result::Result; diff --git a/rust/crates/mailer-security/src/ip_reputation.rs b/rust/crates/mailer-security/src/ip_reputation.rs new file mode 100644 index 0000000..32425d7 --- /dev/null +++ b/rust/crates/mailer-security/src/ip_reputation.rs @@ -0,0 +1,280 @@ +use hickory_resolver::TokioResolver; +use serde::{Deserialize, Serialize}; +use std::net::{IpAddr, Ipv4Addr}; + +use crate::error::Result; + +/// Default DNSBL servers to check, same as the TypeScript IPReputationChecker. +pub const DEFAULT_DNSBL_SERVERS: &[&str] = &[ + "zen.spamhaus.org", + "bl.spamcop.net", + "b.barracudacentral.org", + "spam.dnsbl.sorbs.net", + "dnsbl.sorbs.net", + "cbl.abuseat.org", + "xbl.spamhaus.org", + "pbl.spamhaus.org", + "dnsbl-1.uceprotect.net", + "psbl.surriel.com", +]; + +/// Result of a DNSBL check. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DnsblResult { + /// IP address that was checked. + pub ip: String, + /// Number of DNSBL servers that list this IP. + pub listed_count: usize, + /// Names of DNSBL servers that list this IP. + pub listed_on: Vec, + /// Total number of DNSBL servers checked. + pub total_checked: usize, +} + +/// Result of a full IP reputation check. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReputationResult { + /// Reputation score: 0 (worst) to 100 (best). + pub score: u8, + /// Whether the IP is considered spam source. + pub is_spam: bool, + /// IP address that was checked. + pub ip: String, + /// DNSBL results. + pub dnsbl: DnsblResult, + /// Heuristic IP type classification. + pub ip_type: IpType, +} + +/// Heuristic IP type classification. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum IpType { + Residential, + Datacenter, + Proxy, + Tor, + Vpn, + Unknown, +} + +/// Risk level based on reputation score. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RiskLevel { + /// Score < 20 + High, + /// Score 20-49 + Medium, + /// Score 50-79 + Low, + /// Score >= 80 + Trusted, +} + +/// Get the risk level for a reputation score. +pub fn risk_level(score: u8) -> RiskLevel { + match score { + 0..=19 => RiskLevel::High, + 20..=49 => RiskLevel::Medium, + 50..=79 => RiskLevel::Low, + _ => RiskLevel::Trusted, + } +} + +/// Check an IP against DNSBL servers. +/// +/// * `ip` - The IP address to check (must be IPv4) +/// * `dnsbl_servers` - DNSBL servers to query (use `DEFAULT_DNSBL_SERVERS` for defaults) +/// * `resolver` - DNS resolver to use +pub async fn check_dnsbl( + ip: IpAddr, + dnsbl_servers: &[&str], + resolver: &TokioResolver, +) -> Result { + let ipv4 = match ip { + IpAddr::V4(v4) => v4, + IpAddr::V6(_) => { + // IPv6 DNSBL is less common; return clean result + return Ok(DnsblResult { + ip: ip.to_string(), + listed_count: 0, + listed_on: Vec::new(), + total_checked: 0, + }); + } + }; + + let reversed = reverse_ipv4(ipv4); + let total = dnsbl_servers.len(); + + // Query all DNSBL servers in parallel + let mut handles = Vec::with_capacity(total); + for &server in dnsbl_servers { + let query = format!("{}.{}", reversed, server); + let resolver = resolver.clone(); + let server_name = server.to_string(); + handles.push(tokio::spawn(async move { + match resolver.lookup_ip(&query).await { + Ok(_) => Some(server_name), // IP is listed + Err(_) => None, // IP is not listed (NXDOMAIN) + } + })); + } + + let mut listed_on = Vec::new(); + for handle in handles { + match handle.await { + Ok(Some(server)) => listed_on.push(server), + _ => {} + } + } + + Ok(DnsblResult { + ip: ip.to_string(), + listed_count: listed_on.len(), + listed_on, + total_checked: total, + }) +} + +/// Full IP reputation check: DNSBL + heuristic classification + scoring. +pub async fn check_reputation( + ip: IpAddr, + dnsbl_servers: &[&str], + resolver: &TokioResolver, +) -> Result { + let dnsbl = check_dnsbl(ip, dnsbl_servers, resolver).await?; + let ip_type = classify_ip(ip); + + // Scoring: start at 100 + let mut score: i16 = 100; + + // Subtract 10 per DNSBL listing + score -= (dnsbl.listed_count as i16) * 10; + + // Subtract 30 for suspicious IP types + match ip_type { + IpType::Proxy | IpType::Tor | IpType::Vpn => { + score -= 30; + } + _ => {} + } + + let score = score.clamp(0, 100) as u8; + let is_spam = score < 50; + + Ok(ReputationResult { + score, + is_spam, + ip: ip.to_string(), + dnsbl, + ip_type, + }) +} + +/// Reverse IPv4 octets for DNSBL queries: "1.2.3.4" -> "4.3.2.1". +fn reverse_ipv4(ip: Ipv4Addr) -> String { + let octets = ip.octets(); + format!("{}.{}.{}.{}", octets[3], octets[2], octets[1], octets[0]) +} + +/// Heuristic IP type classification based on well-known prefix ranges. +/// Same heuristics as the TypeScript IPReputationChecker. +fn classify_ip(ip: IpAddr) -> IpType { + let ip_str = ip.to_string(); + + // Known Tor exit node prefixes + if ip_str.starts_with("171.25.") + || ip_str.starts_with("185.220.") + || ip_str.starts_with("95.216.") + { + return IpType::Tor; + } + + // Known VPN provider prefixes + if ip_str.starts_with("185.156.") || ip_str.starts_with("37.120.") { + return IpType::Vpn; + } + + // Known proxy prefixes + if ip_str.starts_with("34.92.") || ip_str.starts_with("34.206.") { + return IpType::Proxy; + } + + // Major cloud provider prefixes (datacenter) + if ip_str.starts_with("13.") + || ip_str.starts_with("35.") + || ip_str.starts_with("52.") + || ip_str.starts_with("34.") + || ip_str.starts_with("104.") + { + return IpType::Datacenter; + } + + IpType::Residential +} + +/// Validate an IPv4 address string. +pub fn is_valid_ipv4(ip: &str) -> bool { + ip.parse::().is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_reverse_ipv4() { + let ip: Ipv4Addr = "1.2.3.4".parse().unwrap(); + assert_eq!(reverse_ipv4(ip), "4.3.2.1"); + + let ip: Ipv4Addr = "192.168.1.100".parse().unwrap(); + assert_eq!(reverse_ipv4(ip), "100.1.168.192"); + } + + #[test] + fn test_classify_ip() { + assert_eq!( + classify_ip("171.25.193.20".parse().unwrap()), + IpType::Tor + ); + assert_eq!( + classify_ip("185.156.73.1".parse().unwrap()), + IpType::Vpn + ); + assert_eq!( + classify_ip("34.92.1.1".parse().unwrap()), + IpType::Proxy + ); + assert_eq!( + classify_ip("52.0.0.1".parse().unwrap()), + IpType::Datacenter + ); + assert_eq!( + classify_ip("203.0.113.1".parse().unwrap()), + IpType::Residential + ); + } + + #[test] + fn test_risk_level() { + assert_eq!(risk_level(10), RiskLevel::High); + assert_eq!(risk_level(30), RiskLevel::Medium); + assert_eq!(risk_level(60), RiskLevel::Low); + assert_eq!(risk_level(90), RiskLevel::Trusted); + } + + #[test] + fn test_is_valid_ipv4() { + assert!(is_valid_ipv4("1.2.3.4")); + assert!(is_valid_ipv4("255.255.255.255")); + assert!(!is_valid_ipv4("999.999.999.999")); + assert!(!is_valid_ipv4("not-an-ip")); + } + + #[test] + fn test_default_dnsbl_servers() { + assert_eq!(DEFAULT_DNSBL_SERVERS.len(), 10); + assert!(DEFAULT_DNSBL_SERVERS.contains(&"zen.spamhaus.org")); + } +} diff --git a/rust/crates/mailer-security/src/lib.rs b/rust/crates/mailer-security/src/lib.rs index 29cc5d1..fc3f772 100644 --- a/rust/crates/mailer-security/src/lib.rs +++ b/rust/crates/mailer-security/src/lib.rs @@ -1,12 +1,36 @@ -//! mailer-security: DKIM, SPF, and DMARC verification. +//! mailer-security: DKIM, SPF, DMARC verification, and IP reputation checking. + +pub mod dkim; +pub mod dmarc; +pub mod error; +pub mod ip_reputation; +pub mod spf; + +// Re-exports for convenience +pub use dkim::{dkim_dns_record_value, sign_dkim, verify_dkim, DkimVerificationResult}; +pub use dmarc::{check_dmarc, DmarcPolicy, DmarcResult}; +pub use error::{Result, SecurityError}; +pub use ip_reputation::{ + check_dnsbl, check_reputation, risk_level, DnsblResult, IpType, ReputationResult, RiskLevel, + DEFAULT_DNSBL_SERVERS, +}; +pub use spf::{check_spf, check_spf_ehlo, received_spf_header, SpfResult}; + +// Re-export mail-auth's MessageAuthenticator for callers to construct +pub use mail_auth::MessageAuthenticator; pub use mailer_core; -/// Placeholder for DKIM/SPF/DMARC implementation. +/// Crate version. pub fn version() -> &'static str { env!("CARGO_PKG_VERSION") } +/// Create a MessageAuthenticator using Cloudflare DNS over TLS. +pub fn default_authenticator() -> std::result::Result> { + Ok(MessageAuthenticator::new_cloudflare_tls()?) +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/crates/mailer-security/src/spf.rs b/rust/crates/mailer-security/src/spf.rs new file mode 100644 index 0000000..4959bb3 --- /dev/null +++ b/rust/crates/mailer-security/src/spf.rs @@ -0,0 +1,145 @@ +use mail_auth::spf::verify::SpfParameters; +use mail_auth::{MessageAuthenticator, SpfResult as MailAuthSpfResult}; +use serde::{Deserialize, Serialize}; +use std::net::IpAddr; + +use crate::error::Result; + +/// SPF verification result. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpfResult { + /// The SPF result: "pass", "fail", "softfail", "neutral", "temperror", "permerror", "none". + pub result: String, + /// The domain that was checked. + pub domain: String, + /// The IP address that was checked. + pub ip: String, + /// Optional explanation string from the SPF record. + pub explanation: Option, +} + +impl SpfResult { + /// Whether the SPF check passed. + pub fn passed(&self) -> bool { + self.result == "pass" + } +} + +/// Check SPF for a given sender IP, HELO domain, and MAIL FROM address. +/// +/// * `ip` - The connecting client's IP address +/// * `helo_domain` - The domain from the SMTP EHLO/HELO command +/// * `host_domain` - Your receiving server's hostname +/// * `mail_from` - The full MAIL FROM address (e.g., "sender@example.com") +pub async fn check_spf( + ip: IpAddr, + helo_domain: &str, + host_domain: &str, + mail_from: &str, + authenticator: &MessageAuthenticator, +) -> Result { + let output = authenticator + .verify_spf(SpfParameters::verify_mail_from( + ip, + helo_domain, + host_domain, + mail_from, + )) + .await; + + let result_str = match output.result() { + MailAuthSpfResult::Pass => "pass", + MailAuthSpfResult::Fail => "fail", + MailAuthSpfResult::SoftFail => "softfail", + MailAuthSpfResult::Neutral => "neutral", + MailAuthSpfResult::TempError => "temperror", + MailAuthSpfResult::PermError => "permerror", + MailAuthSpfResult::None => "none", + }; + + Ok(SpfResult { + result: result_str.to_string(), + domain: output.domain().to_string(), + ip: ip.to_string(), + explanation: output.explanation().map(|s| s.to_string()), + }) +} + +/// Check SPF for the EHLO identity (before MAIL FROM). +pub async fn check_spf_ehlo( + ip: IpAddr, + helo_domain: &str, + host_domain: &str, + authenticator: &MessageAuthenticator, +) -> Result { + let output = authenticator + .verify_spf(SpfParameters::verify_ehlo(ip, helo_domain, host_domain)) + .await; + + let result_str = match output.result() { + MailAuthSpfResult::Pass => "pass", + MailAuthSpfResult::Fail => "fail", + MailAuthSpfResult::SoftFail => "softfail", + MailAuthSpfResult::Neutral => "neutral", + MailAuthSpfResult::TempError => "temperror", + MailAuthSpfResult::PermError => "permerror", + MailAuthSpfResult::None => "none", + }; + + Ok(SpfResult { + result: result_str.to_string(), + domain: helo_domain.to_string(), + ip: ip.to_string(), + explanation: output.explanation().map(|s| s.to_string()), + }) +} + +/// Build a Received-SPF header value. +pub fn received_spf_header(result: &SpfResult) -> String { + format!( + "{} (domain of {} designates {} as permitted sender) receiver={}; client-ip={};", + result.result, + result.domain, + result.ip, + result.domain, + result.ip, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_spf_result_passed() { + let result = SpfResult { + result: "pass".to_string(), + domain: "example.com".to_string(), + ip: "1.2.3.4".to_string(), + explanation: None, + }; + assert!(result.passed()); + + let result = SpfResult { + result: "fail".to_string(), + domain: "example.com".to_string(), + ip: "1.2.3.4".to_string(), + explanation: None, + }; + assert!(!result.passed()); + } + + #[test] + fn test_received_spf_header() { + let result = SpfResult { + result: "pass".to_string(), + domain: "example.com".to_string(), + ip: "1.2.3.4".to_string(), + explanation: None, + }; + let header = received_spf_header(&result); + assert!(header.contains("pass")); + assert!(header.contains("example.com")); + assert!(header.contains("1.2.3.4")); + } +}