feat(rust): implement mailer-core and mailer-security crates with CLI
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.
This commit is contained in:
551
rust/Cargo.lock
generated
551
rust/Cargo.lock
generated
@@ -41,6 +41,56 @@ dependencies = [
|
|||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "arbitrary"
|
name = "arbitrary"
|
||||||
version = "1.4.2"
|
version = "1.4.2"
|
||||||
@@ -83,12 +133,6 @@ dependencies = [
|
|||||||
"fs_extra",
|
"fs_extra",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "base64"
|
|
||||||
version = "0.21.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -116,12 +160,6 @@ version = "3.19.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "byteorder"
|
|
||||||
version = "1.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -130,21 +168,11 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bzip2"
|
name = "bzip2"
|
||||||
version = "0.5.2"
|
version = "0.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
|
checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bzip2-sys",
|
"libbz2-rs-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",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -171,7 +199,7 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e"
|
checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -185,6 +213,46 @@ dependencies = [
|
|||||||
"inout",
|
"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]]
|
[[package]]
|
||||||
name = "cmake"
|
name = "cmake"
|
||||||
version = "0.1.57"
|
version = "0.1.57"
|
||||||
@@ -194,6 +262,12 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "constant_time_eq"
|
name = "constant_time_eq"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -417,6 +491,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
|
"zlib-rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -486,12 +561,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gethostname"
|
name = "gethostname"
|
||||||
version = "0.4.3"
|
version = "1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818"
|
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"rustix",
|
||||||
"windows-targets 0.48.5",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -512,11 +587,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"js-sys",
|
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -531,40 +604,23 @@ version = "0.16.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
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]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
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]]
|
[[package]]
|
||||||
name = "hickory-proto"
|
name = "hickory-proto"
|
||||||
version = "0.25.2"
|
version = "0.25.2"
|
||||||
@@ -581,9 +637,9 @@ dependencies = [
|
|||||||
"idna",
|
"idna",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rand 0.9.2",
|
"rand",
|
||||||
"ring",
|
"ring",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"tinyvec",
|
"tinyvec",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -591,26 +647,34 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hickory-resolver"
|
name = "hickory-proto"
|
||||||
version = "0.24.4"
|
version = "0.26.0-alpha.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e"
|
checksum = "a62d7684f766b0f96344be88c023f9b6650039aea09d526b4974cce302eb61b1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"bitflags",
|
||||||
|
"bytes",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"data-encoding",
|
||||||
|
"enum-as-inner",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-io",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hickory-proto 0.24.4",
|
"idna",
|
||||||
"ipconfig",
|
"ipnet",
|
||||||
"lru-cache",
|
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"parking_lot",
|
"rand",
|
||||||
"rand 0.8.5",
|
"ring",
|
||||||
"resolv-conf",
|
"rustls",
|
||||||
"rustls 0.21.12",
|
"rustls-pki-types",
|
||||||
"smallvec",
|
"thiserror",
|
||||||
"thiserror 1.0.69",
|
"time",
|
||||||
|
"tinyvec",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls 0.24.1",
|
"tokio-rustls",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -626,14 +690,37 @@ dependencies = [
|
|||||||
"moka",
|
"moka",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rand 0.9.2",
|
"rand",
|
||||||
"resolv-conf",
|
"resolv-conf",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "hmac"
|
name = "hmac"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -782,6 +869,12 @@ version = "2.11.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.17"
|
version = "1.0.17"
|
||||||
@@ -808,6 +901,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libbz2-rs-sys"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.181"
|
version = "0.2.181"
|
||||||
@@ -825,10 +924,10 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linked-hash-map"
|
name = "linux-raw-sys"
|
||||||
version = "0.5.6"
|
version = "0.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
|
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
@@ -852,51 +951,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru-cache"
|
name = "lzma-rust2"
|
||||||
version = "0.1.2"
|
version = "0.13.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c"
|
checksum = "c60a23ffb90d527e23192f1246b14746e2f7f071cb84476dd879071696c18a4a"
|
||||||
dependencies = [
|
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",
|
"crc",
|
||||||
]
|
"sha2",
|
||||||
|
|
||||||
[[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",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mail-auth"
|
name = "mail-auth"
|
||||||
version = "0.4.3"
|
version = "0.7.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9bd9d657de66a3d5ac360c3eab8c9f5cac2565f2b97cc032d5de4c900ef470de"
|
checksum = "5b7da45f78cc525d3750b623c967ae21c0cd28b2e6a9a2ee4b536a7cce3b21ce"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"flate2",
|
"flate2",
|
||||||
"hickory-resolver 0.24.4",
|
"hashify",
|
||||||
"lru-cache",
|
"hickory-resolver 0.26.0-alpha.1",
|
||||||
"mail-builder",
|
"mail-builder",
|
||||||
"mail-parser",
|
"mail-parser",
|
||||||
"parking_lot",
|
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
|
"quick_cache",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pemfile 2.2.0",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"zip",
|
"zip",
|
||||||
@@ -904,26 +983,29 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mail-builder"
|
name = "mail-builder"
|
||||||
version = "0.3.2"
|
version = "0.4.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "25f5871d5270ed80f2ee750b95600c8d69b05f8653ad3be913b2ad2e924fefcb"
|
checksum = "900998f307338c4013a28ab14d760b784067324b164448c6d98a89e44810473b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gethostname",
|
"gethostname",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mail-parser"
|
name = "mail-parser"
|
||||||
version = "0.9.4"
|
version = "0.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "93c3b9e5d8b17faf573330bbc43b37d6e918c0a3bf8a88e7d0a220ebc84af9fc"
|
checksum = "dcf4390741c4e6fa330bdeccdfb580815dbb462952de91838b723357985119a3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
|
"hashify",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mailer-bin"
|
name = "mailer-bin"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"hickory-resolver 0.25.2",
|
||||||
"mailer-core",
|
"mailer-core",
|
||||||
"mailer-security",
|
"mailer-security",
|
||||||
"mailer-smtp",
|
"mailer-smtp",
|
||||||
@@ -937,12 +1019,15 @@ dependencies = [
|
|||||||
name = "mailer-core"
|
name = "mailer-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"mailparse",
|
"mailparse",
|
||||||
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -964,11 +1049,17 @@ dependencies = [
|
|||||||
name = "mailer-security"
|
name = "mailer-security"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"hickory-resolver 0.25.2",
|
||||||
|
"ipnet",
|
||||||
"mail-auth",
|
"mail-auth",
|
||||||
"mailer-core",
|
"mailer-core",
|
||||||
|
"psl",
|
||||||
"ring",
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"serde_json",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -981,17 +1072,17 @@ dependencies = [
|
|||||||
"hickory-resolver 0.25.2",
|
"hickory-resolver 0.25.2",
|
||||||
"mailer-core",
|
"mailer-core",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls 0.26.4",
|
"tokio-rustls",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mailparse"
|
name = "mailparse"
|
||||||
version = "0.15.0"
|
version = "0.16.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3da03d5980411a724e8aaf7b61a7b5e386ec55a7fb49ee3d0ff79efc7e5e7c7e"
|
checksum = "60819a97ddcb831a5614eb3b0174f3620e793e97e09195a395bfa948fd68ed2f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"charset",
|
"charset",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
@@ -1118,6 +1209,12 @@ dependencies = [
|
|||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
@@ -1196,6 +1293,12 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppmd-rust"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -1215,14 +1318,41 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "psl"
|
||||||
version = "0.32.0"
|
version = "2.1.188"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
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 = [
|
dependencies = [
|
||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.44"
|
version = "1.0.44"
|
||||||
@@ -1244,35 +1374,14 @@ version = "5.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
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]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.9.2"
|
version = "0.9.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand_chacha 0.9.0",
|
"rand_chacha",
|
||||||
"rand_core 0.9.5",
|
"rand_core",
|
||||||
]
|
|
||||||
|
|
||||||
[[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",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1282,16 +1391,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"ppv-lite86",
|
||||||
"rand_core 0.9.5",
|
"rand_core",
|
||||||
]
|
|
||||||
|
|
||||||
[[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",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1362,15 +1462,16 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustix"
|
||||||
version = "0.21.12"
|
version = "1.1.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
|
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"bitflags",
|
||||||
"ring",
|
"errno",
|
||||||
"rustls-webpki 0.101.7",
|
"libc",
|
||||||
"sct",
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1382,30 +1483,13 @@ dependencies = [
|
|||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-webpki 0.103.9",
|
"rustls-webpki",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.14.0"
|
version = "1.14.0"
|
||||||
@@ -1415,16 +1499,6 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.9"
|
version = "0.103.9"
|
||||||
@@ -1449,16 +1523,6 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
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]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.27"
|
version = "1.0.27"
|
||||||
@@ -1519,6 +1583,17 @@ dependencies = [
|
|||||||
"digest",
|
"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]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -1579,6 +1654,12 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@@ -1613,33 +1694,13 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
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]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.18"
|
version = "2.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl 2.0.18",
|
"thiserror-impl",
|
||||||
]
|
|
||||||
|
|
||||||
[[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",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1725,23 +1786,13 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustls 0.23.36",
|
"rustls",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1818,6 +1869,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.20.0"
|
version = "1.20.0"
|
||||||
@@ -2151,15 +2208,6 @@ version = "0.6.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
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]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -2279,34 +2327,37 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zip"
|
name = "zip"
|
||||||
version = "2.4.2"
|
version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"arbitrary",
|
"arbitrary",
|
||||||
"bzip2",
|
"bzip2",
|
||||||
"constant_time_eq",
|
"constant_time_eq",
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"crossbeam-utils",
|
|
||||||
"deflate64",
|
"deflate64",
|
||||||
"displaydoc",
|
|
||||||
"flate2",
|
"flate2",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"hmac",
|
"hmac",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"lzma-rs",
|
"lzma-rust2",
|
||||||
"memchr",
|
"memchr",
|
||||||
"pbkdf2",
|
"pbkdf2",
|
||||||
|
"ppmd-rust",
|
||||||
"sha1",
|
"sha1",
|
||||||
"thiserror 2.0.18",
|
|
||||||
"time",
|
"time",
|
||||||
"xz2",
|
|
||||||
"zeroize",
|
"zeroize",
|
||||||
"zopfli",
|
"zopfli",
|
||||||
"zstd",
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zlib-rs"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.20"
|
version = "1.0.20"
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ tracing.workspace = true
|
|||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
|
hickory-resolver.workspace = true
|
||||||
|
|||||||
@@ -1,6 +1,120 @@
|
|||||||
//! 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<Commands>,
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
|
||||||
|
/// HELO domain for SPF check
|
||||||
|
#[arg(long)]
|
||||||
|
helo: Option<String>,
|
||||||
|
|
||||||
|
/// Receiving server hostname
|
||||||
|
#[arg(long, default_value = "localhost")]
|
||||||
|
hostname: String,
|
||||||
|
|
||||||
|
/// MAIL FROM address for SPF check
|
||||||
|
#[arg(long)]
|
||||||
|
mail_from: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// 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_json::Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct IpcEvent {
|
||||||
|
event: String,
|
||||||
|
data: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
if cli.management {
|
||||||
|
run_management_mode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Some(Commands::Version) | None => {
|
||||||
println!(
|
println!(
|
||||||
"mailer-bin v{} (core: {}, smtp: {}, security: {})",
|
"mailer-bin v{} (core: {}, smtp: {}, security: {})",
|
||||||
env!("CARGO_PKG_VERSION"),
|
env!("CARGO_PKG_VERSION"),
|
||||||
@@ -8,4 +122,437 @@ fn main() {
|
|||||||
mailer_smtp::version(),
|
mailer_smtp::version(),
|
||||||
mailer_security::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::<IpAddr>() {
|
||||||
|
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::<IpAddr>() {
|
||||||
|
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::<IpAddr>() {
|
||||||
|
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)),
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
485
rust/crates/mailer-core/src/bounce.rs
Normal file
485
rust/crates/mailer-core/src/bounce.rs
Normal file
@@ -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<Regex>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All bounce detection patterns, compiled once.
|
||||||
|
static BOUNCE_PATTERNS: LazyLock<Vec<BouncePatterns>> = 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<Regex> = 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<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)(?:failed recipient|to[:=]\s*|recipient:|delivery failed:)\s*<?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})>?")
|
||||||
|
.expect("invalid bounce recipient regex")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Regex for extracting diagnostic code.
|
||||||
|
static DIAGNOSTIC_CODE_RE: LazyLock<Regex> = 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<Regex> = 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<Regex> = 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<Regex> = 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<Regex> {
|
||||||
|
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<BounceDetection> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
411
rust/crates/mailer-core/src/email.rs
Normal file
411
rust/crates/mailer-core/src/email.rs
Normal file
@@ -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 <user@example.com>".
|
||||||
|
pub fn parse(input: &str) -> Result<Self> {
|
||||||
|
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"
|
||||||
|
/// - "<user@example.com>"
|
||||||
|
/// - "John Doe <user@example.com>"
|
||||||
|
pub fn extract_email_address(input: &str) -> Option<String> {
|
||||||
|
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<u8>,
|
||||||
|
pub content_type: String,
|
||||||
|
pub content_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serde helper for base64-encoding Vec<u8> in JSON.
|
||||||
|
mod serde_bytes_base64 {
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use base64::Engine;
|
||||||
|
use serde::{Deserialize, Deserializer, Serializer};
|
||||||
|
|
||||||
|
pub fn serialize<S: Serializer>(data: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.serialize_str(&STANDARD.encode(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, 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<String>,
|
||||||
|
pub cc: Vec<String>,
|
||||||
|
pub bcc: Vec<String>,
|
||||||
|
pub subject: String,
|
||||||
|
pub text: String,
|
||||||
|
pub html: Option<String>,
|
||||||
|
pub attachments: Vec<Attachment>,
|
||||||
|
pub headers: HashMap<String, String>,
|
||||||
|
pub priority: Priority,
|
||||||
|
pub might_be_spam: bool,
|
||||||
|
message_id: Option<String>,
|
||||||
|
envelope_from: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String> {
|
||||||
|
EmailAddress::parse(&self.from)
|
||||||
|
.ok()
|
||||||
|
.map(|addr| addr.domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the sender address (bare email, no display name).
|
||||||
|
pub fn from_address(&self) -> Option<String> {
|
||||||
|
extract_email_address(&self.from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all recipients (to + cc + bcc), deduplicated.
|
||||||
|
pub fn all_recipients(&self) -> Vec<String> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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 <john@example.com>").unwrap();
|
||||||
|
assert_eq!(addr.local, "john");
|
||||||
|
assert_eq!(addr.domain, "example.com");
|
||||||
|
|
||||||
|
let addr = EmailAddress::parse("<admin@test.org>").unwrap();
|
||||||
|
assert_eq!(addr.local, "admin");
|
||||||
|
assert_eq!(addr.domain, "test.org");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_email_address() {
|
||||||
|
assert_eq!(
|
||||||
|
extract_email_address("John <john@example.com>"),
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
31
rust/crates/mailer-core/src/error.rs
Normal file
31
rust/crates/mailer-core/src/error.rs
Normal file
@@ -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<T> = std::result::Result<T, MailerError>;
|
||||||
@@ -1,9 +1,25 @@
|
|||||||
//! mailer-core: Email model, validation, and RFC 5322 primitives.
|
//! 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.
|
/// Re-export mailparse for MIME parsing.
|
||||||
pub use mailparse;
|
pub use mailparse;
|
||||||
|
|
||||||
/// Placeholder for email address validation and data types.
|
/// Crate version.
|
||||||
pub fn version() -> &'static str {
|
pub fn version() -> &'static str {
|
||||||
env!("CARGO_PKG_VERSION")
|
env!("CARGO_PKG_VERSION")
|
||||||
}
|
}
|
||||||
|
|||||||
377
rust/crates/mailer-core/src/mime.rs
Normal file
377
rust/crates/mailer-core/src/mime.rs
Normal file
@@ -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<String> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !email.cc.is_empty() {
|
||||||
|
output.push_str(&format!(
|
||||||
|
"Cc: {}\r\n",
|
||||||
|
email
|
||||||
|
.cc
|
||||||
|
.iter()
|
||||||
|
.map(|a| Email::sanitize_string(a))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.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("<test@example.com>");
|
||||||
|
|
||||||
|
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("<p>HTML content</p>");
|
||||||
|
email.set_message_id("<test@example.com>");
|
||||||
|
|
||||||
|
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("<test@example.com>");
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
178
rust/crates/mailer-core/src/validation.rs
Normal file
178
rust/crates/mailer-core/src/validation.rs
Normal file
@@ -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<Regex> = 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
148
rust/crates/mailer-security/src/dkim.rs
Normal file
148
rust/crates/mailer-security/src/dkim.rs
Normal file
@@ -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<String>,
|
||||||
|
/// The selector (s= tag).
|
||||||
|
pub selector: Option<String>,
|
||||||
|
/// Result status: "pass", "fail", "permerror", "temperror", "none".
|
||||||
|
pub status: String,
|
||||||
|
/// Human-readable details.
|
||||||
|
pub details: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Vec<DkimVerificationResult>> {
|
||||||
|
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<String> {
|
||||||
|
// 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::<Sha256>::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::<Vec<_>>()
|
||||||
|
.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());
|
||||||
|
}
|
||||||
|
}
|
||||||
127
rust/crates/mailer-security/src/dmarc.rs
Normal file
127
rust/crates/mailer-security/src/dmarc.rs
Normal file
@@ -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<Policy> 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<DmarcResult> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
rust/crates/mailer-security/src/error.rs
Normal file
31
rust/crates/mailer-security/src/error.rs
Normal file
@@ -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<T> = std::result::Result<T, SecurityError>;
|
||||||
280
rust/crates/mailer-security/src/ip_reputation.rs
Normal file
280
rust/crates/mailer-security/src/ip_reputation.rs
Normal file
@@ -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<String>,
|
||||||
|
/// 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<DnsblResult> {
|
||||||
|
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<ReputationResult> {
|
||||||
|
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::<Ipv4Addr>().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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
pub use mailer_core;
|
||||||
|
|
||||||
/// Placeholder for DKIM/SPF/DMARC implementation.
|
/// Crate version.
|
||||||
pub fn version() -> &'static str {
|
pub fn version() -> &'static str {
|
||||||
env!("CARGO_PKG_VERSION")
|
env!("CARGO_PKG_VERSION")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a MessageAuthenticator using Cloudflare DNS over TLS.
|
||||||
|
pub fn default_authenticator() -> std::result::Result<MessageAuthenticator, Box<dyn std::error::Error>> {
|
||||||
|
Ok(MessageAuthenticator::new_cloudflare_tls()?)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
145
rust/crates/mailer-security/src/spf.rs
Normal file
145
rust/crates/mailer-security/src/spf.rs
Normal file
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SpfResult> {
|
||||||
|
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<SpfResult> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user