diff --git a/Cargo.lock b/Cargo.lock index dce2627..3bc5085 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,6 +193,15 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -426,6 +435,45 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.17", + "time 0.3.47", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "async-channel" version = "1.9.0" @@ -505,7 +553,7 @@ dependencies = [ "futures", "imap-proto", "log", - "nom", + "nom 7.1.3", "pin-project", "pin-utils", "self_cell", @@ -699,6 +747,7 @@ dependencies = [ "imap-proto", "itertools", "itoa", + "lettre", "lru 0.16.3", "mail-parser", "mail-send", @@ -717,6 +766,7 @@ dependencies = [ "poem-openapi", "r2d2", "rand 0.10.0", + "rcgen", "refinery", "refinery-core", "regex", @@ -724,6 +774,7 @@ dependencies = [ "ring", "rust-embed", "rustls", + "rustls-pemfile", "rustls-pki-types", "semver 1.0.27", "serde", @@ -1064,6 +1115,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "cipher" version = "0.2.5" @@ -1270,6 +1331,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1428,7 +1499,7 @@ checksum = "79fc3b6dd0b87ba36e565715bf9a2ced221311db47bd18011676f24a6066edbc" dependencies = [ "curl-sys", "libc", - "openssl-probe", + "openssl-probe 0.1.6", "openssl-sys", "schannel", "socket2 0.6.1", @@ -1547,6 +1618,20 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.5" @@ -1676,6 +1761,16 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + [[package]] name = "email_address" version = "0.2.9" @@ -1876,6 +1971,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -2197,6 +2307,10 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", + "allocator-api2", +] [[package]] name = "hashbrown" @@ -2371,6 +2485,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "html2text" version = "0.16.7" @@ -2719,7 +2844,7 @@ version = "0.16.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba1f9b30846c3d04371159ef3a0413ce7c1ae0a8c619cd255c60b3d902553f22" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -2880,6 +3005,31 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lettre" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +dependencies = [ + "base64 0.22.1", + "chumsky", + "email-encoding", + "email_address", + "fastrand 2.3.0", + "futures-util", + "hostname", + "httpdate", + "idna 1.1.0", + "mime", + "native-tls", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "socket2 0.6.1", + "tokio", + "url", +] + [[package]] name = "levenshtein_automata" version = "0.2.1" @@ -3363,6 +3513,23 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "native_db" version = "0.8.2" @@ -3444,6 +3611,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nonzero_ext" version = "0.3.0" @@ -3606,6 +3782,15 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "oem_cp" version = "1.3.0" @@ -3616,6 +3801,15 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -3644,12 +3838,44 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "openssl-src" version = "300.5.4+3.5.4" @@ -3730,6 +3956,16 @@ dependencies = [ "hmac 0.12.1", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4044,6 +4280,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "ptr_meta" version = "0.1.4" @@ -4185,6 +4431,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + [[package]] name = "r-efi" version = "5.3.0" @@ -4364,6 +4616,20 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time 0.3.47", + "x509-parser", + "yasna", +] + [[package]] name = "redb" version = "1.5.1" @@ -4674,6 +4940,15 @@ dependencies = [ "semver 1.0.27", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.1.4" @@ -4785,6 +5060,29 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.2.1" @@ -5177,6 +5475,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + [[package]] name = "standback" version = "0.2.17" @@ -5519,7 +5830,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "768fccdc84d60d86235d42d7e4c33acf43c418258ff5952abf07bd7837fcd26b" dependencies = [ - "nom", + "nom 7.1.3", "serde", "serde_json", ] @@ -6975,6 +7286,24 @@ dependencies = [ "tap", ] +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.17", + "time 0.3.47", +] + [[package]] name = "xattr" version = "1.6.1" @@ -6997,6 +7326,15 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time 0.3.47", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 15a4c14..bb32302 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -133,8 +133,10 @@ arrow = { version = "56.2.0", features = ["ffi"] } r2d2 = { version = "0.8", default-features = false } refinery = { version = "0.9", default-features = false } refinery-core = { version = "0.9", default-features = false } - +rcgen = "0.14.7" +rustls-pemfile = "2.2.0" [dev-dependencies] #bincode = "1.3.3" #secret-lib = "1.0.0" tempfile = "3.26.0" +lettre = "0.11.19" \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index de5ed53..f07f999 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,14 +22,17 @@ use bichon::{ common::rustls::RustMailerTls, context::{executors::BichonContext, Initialize}, duckdb::init::DuckDBManager, - error::BichonResult, + error::{code::ErrorCode, BichonResult}, logger, rest::start_http_server, + settings::cli::SETTINGS, + smtp::{start_smtp_server, SmtpServer}, tasks::PeriodicTasks, }, + raise_error, }; use mimalloc::MiMalloc; -use tracing::info; +use tracing::{error, info}; use bichon::modules::{ common::signal::SignalManager, settings::dir::DataDirManager, users::manager::UserManager, @@ -61,7 +64,32 @@ async fn main() -> BichonResult<()> { return Err(error); } + let mut smtp_service: Option = None; + if SETTINGS.bichon_enable_smtp { + info!("SMTP service is enabled, starting..."); + match start_smtp_server().await { + Ok(server) => { + info!("SMTP server listening on: {}", server.smtp_addr); + smtp_service = Some(server); + } + Err(e) => { + error!("Failed to start SMTP server: {}", e); + return Err(raise_error!(format!("{:#?}", e), ErrorCode::InternalError)); + } + } + } else { + info!("SMTP service is disabled by configuration."); + } + start_http_server().await?; + + if let Some(server) = smtp_service { + info!("Shutting down SMTP server..."); + server.stop().await; + info!("SMTP server stopped."); + } + + info!("Bichon server stopped."); Ok(()) } diff --git a/src/modules/account/migration.rs b/src/modules/account/migration.rs index 44ad6c8..836f82b 100644 --- a/src/modules/account/migration.rs +++ b/src/modules/account/migration.rs @@ -421,6 +421,17 @@ impl AccountV4 { list_all_impl(DB_MANAGER.meta_db()).await } + pub async fn find_by_email(email: &str) -> BichonResult> { + let all: Vec = list_all_impl(DB_MANAGER.meta_db()).await?; + let target_email = email.trim().to_lowercase(); + + let first_match = all + .into_iter() + .find(|acc| acc.email.to_lowercase() == target_email); + + Ok(first_match) + } + pub async fn minimal_list(only_nosync: bool) -> BichonResult> { let result = list_all_impl(DB_MANAGER.meta_db()) .await? diff --git a/src/modules/common/auth.rs b/src/modules/common/auth.rs index 66d2d2b..b75f9f2 100644 --- a/src/modules/common/auth.rs +++ b/src/modules/common/auth.rs @@ -93,6 +93,37 @@ impl ClientContext { )) } + pub async fn check_has_permission(user: &UserModel, account_id: Option, permission: &str) -> bool { + if user.is_admin().await { + return true; + } + + let mut global_perms = HashSet::new(); + for rid in &user.global_roles { + if let Some(role) = UserRole::find(*rid).await.ok().flatten() { + global_perms.extend(role.permissions); + } + } + + if Self::check_global_logic(&global_perms, permission) { + return true; + } + + if let Some(aid) = account_id { + if let Some(role_id) = user.account_access_map.get(&aid) { + if let Some(role) = UserRole::find(*role_id).await.ok().flatten() { + if role.permissions.contains(&permission.to_string()) + || Self::check_account_logic(&role.permissions, permission) + { + return true; + } + } + } + } + + false + } + pub async fn has_permission(&self, account_id: Option, permission: &str) -> bool { if self.user.is_admin().await { return true; @@ -105,7 +136,7 @@ impl ClientContext { } } - if self.check_global_logic(&global_perms, permission) { + if Self::check_global_logic(&global_perms, permission) { return true; } @@ -113,7 +144,7 @@ impl ClientContext { if let Some(role_id) = self.user.account_access_map.get(&aid) { if let Some(role) = UserRole::find(*role_id).await.ok().flatten() { if role.permissions.contains(&permission.to_string()) - || self.check_account_logic(&role.permissions, permission) + || Self::check_account_logic(&role.permissions, permission) { return true; } @@ -124,7 +155,7 @@ impl ClientContext { false } - fn check_global_logic(&self, global: &HashSet, perm: &str) -> bool { + fn check_global_logic(global: &HashSet, perm: &str) -> bool { if global.contains(perm) { return true; } @@ -141,7 +172,7 @@ impl ClientContext { } } - fn check_account_logic(&self, scoped_perms: &BTreeSet, perm: &str) -> bool { + fn check_account_logic(scoped_perms: &BTreeSet, perm: &str) -> bool { if scoped_perms.contains(perm) { return true; } diff --git a/src/modules/envelope/extractor.rs b/src/modules/envelope/extractor.rs index d7e713d..f649dad 100644 --- a/src/modules/envelope/extractor.rs +++ b/src/modules/envelope/extractor.rs @@ -61,6 +61,21 @@ pub fn extract_envelope_from_eml( ) } +pub fn extract_envelope_from_smtp( + body: &[u8], + account_id: u64, + mailbox_id: u64, +) -> BichonResult<(Envelope, Vec)> { + extract_envelope_core( + body, + 0, + body.len() as u32, + utc_now!(), + account_id, + mailbox_id, + ) +} + fn extract_envelope_core( body: &[u8], uid: u32, diff --git a/src/modules/mod.rs b/src/modules/mod.rs index ef52c75..4036442 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -36,6 +36,7 @@ pub mod message; pub mod oauth2; pub mod rest; pub mod settings; +pub mod smtp; pub mod tasks; pub mod token; pub mod users; diff --git a/src/modules/rest/mod.rs b/src/modules/rest/mod.rs index 541ff3d..50c1e5d 100644 --- a/src/modules/rest/mod.rs +++ b/src/modules/rest/mod.rs @@ -18,13 +18,14 @@ use crate::modules::common::error::ErrorCapture; use crate::modules::common::log::Tracing; +use crate::modules::common::signal::SIGNAL_MANAGER; use crate::modules::common::tls::rustls_config; use crate::modules::error::code::ErrorCode; use crate::modules::error::handler::error_handler; use crate::modules::error::BichonResult; use crate::modules::rest::public::login::login; use crate::modules::rest::public::status::get_status; -use crate::modules::{settings::cli::SETTINGS, utils::shutdown::shutdown_signal}; +use crate::modules::settings::cli::SETTINGS; use super::error::ApiErrorResponse; use crate::modules::common::auth::ApiGuard; @@ -137,12 +138,16 @@ pub async fn start_http_server() -> BichonResult<()> { .with_if(SETTINGS.bichon_http_compression_enabled, Compression::new()) .with(CatchPanic::new()); + let mut rx = SIGNAL_MANAGER.subscribe(); + let shutdown_fut = async move { + let _ = rx.recv().await; + }; let server = Server::new(listener) .name("Bichon Service") .idle_timeout(Duration::from_secs(60)) .run_with_graceful_shutdown( route.catch_all_error(error_handler), - shutdown_signal(), + shutdown_fut, Some(Duration::from_secs(5)), ); println!( diff --git a/src/modules/settings/cli.rs b/src/modules/settings/cli.rs index 77522ea..71ac48c 100644 --- a/src/modules/settings/cli.rs +++ b/src/modules/settings/cli.rs @@ -300,6 +300,73 @@ pub struct Settings { help = "Set the Tantivy docstore block size in bytes (default: 2MB)" )] pub bichon_eml_blocksize: usize, + + #[clap( + long, + env, + default_value = "false", + help = "Enable the embedded SMTP server for real-time email receiving" + )] + pub bichon_enable_smtp: bool, + + #[clap( + long, + env, + help = "Path to the SMTP TLS private key file (e.g., key.pem)", + value_parser = ValueParser::new(|s: &str| { + let path = PathBuf::from(s); + if !path.is_absolute() { + return Err("'bichon_smtp_tls_key_path' must be an absolute path".to_string()); + } + if !path.exists() { + return Err(format!("SMTP TLS key file not found: {}", s)); + } + Ok(s.to_string()) + }) + )] + pub bichon_smtp_tls_key_path: Option, + + #[clap( + long, + env, + help = "Path to the SMTP TLS certificate chain file (e.g., cert.pem)", + value_parser = ValueParser::new(|s: &str| { + let path = PathBuf::from(s); + if !path.is_absolute() { + return Err("'bichon_smtp_tls_cert_path' must be an absolute path".to_string()); + } + if !path.exists() { + return Err(format!("SMTP TLS certificate file not found: {}", s)); + } + Ok(s.to_string()) + }) + )] + pub bichon_smtp_tls_cert_path: Option, + + #[clap( + long, + default_value = "2525", + env, + help = "Set the SMTP port for Bichon (e.g., 25 or 2525). Note: Port 25 may require root privileges.", + value_parser = clap::value_parser!(u16).range(1..) + )] + pub bichon_smtp_port: u16, + + #[clap( + long, + env, + default_value = "starttls", + help = "Set the encryption mode for SMTP: 'none', 'starttls', or 'tls'" + )] + pub bichon_smtp_encryption: SmtpEncryptionMode, + + #[clap( + long, + env, + default_value = "true", + help = "Enable SMTP authentication requirement" + )] + pub bichon_smtp_auth_required: bool, } impl Settings { @@ -339,3 +406,23 @@ impl fmt::Display for CompressionAlgorithm { } } } + +#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)] +pub enum SmtpEncryptionMode { + #[clap(name = "none")] + None, + #[clap(name = "starttls")] + Starttls, + #[clap(name = "tls")] + Tls, +} + +impl fmt::Display for SmtpEncryptionMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SmtpEncryptionMode::None => write!(f, "none"), + SmtpEncryptionMode::Starttls => write!(f, "starttls"), + SmtpEncryptionMode::Tls => write!(f, "tls"), + } + } +} diff --git a/src/modules/smtp/mod.rs b/src/modules/smtp/mod.rs new file mode 100644 index 0000000..57f2941 --- /dev/null +++ b/src/modules/smtp/mod.rs @@ -0,0 +1,109 @@ +// +// Copyright (c) 2025 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::net::SocketAddr; + +use tokio::net::TcpListener; +use tokio_rustls::TlsAcceptor; + +use crate::modules::{ + common::signal::SIGNAL_MANAGER, + settings::cli::{SmtpEncryptionMode, SETTINGS}, + smtp::{ + server::{run_smtp_server, run_smtps_server}, + tls::create_acceptor, + }, +}; + +pub mod server; +pub mod stream; +pub mod tls; +#[cfg(test)] +mod tests; + +#[derive(Clone, Default)] +pub struct SmtpConfig { + pub whitelist: Option>, + pub tls_acceptor: Option, + pub auth_required: bool, +} + +pub struct SmtpServer { + pub smtp_addr: SocketAddr, + smtp_handle: tokio::task::JoinHandle<()>, +} + +impl SmtpServer { + pub async fn stop(self) { + let _ = self.smtp_handle.await; + } +} + +pub async fn start_smtp_server() -> std::io::Result { + let smtp_port = SETTINGS.bichon_smtp_port; + + let tls_acceptor: Option = match SETTINGS.bichon_smtp_encryption { + SmtpEncryptionMode::None => None, + SmtpEncryptionMode::Starttls | SmtpEncryptionMode::Tls => Some(create_acceptor().await?), + }; + + let smtp_listener = TcpListener::bind(( + SETTINGS.bichon_bind_ip.clone().unwrap_or("0.0.0.0".into()), + smtp_port, + )) + .await + .map_err(|e| { + if e.kind() == std::io::ErrorKind::AddrInUse { + std::io::Error::other(format!( + "SMTP port {smtp_port} is already in use. Is another instance running?" + )) + } else { + e + } + })?; + let smtp_addr = smtp_listener.local_addr()?; + + let smtp_config = SmtpConfig { + whitelist: None, + tls_acceptor: match SETTINGS.bichon_smtp_encryption { + SmtpEncryptionMode::None | SmtpEncryptionMode::Tls => None, + SmtpEncryptionMode::Starttls => tls_acceptor.clone(), + }, + auth_required: SETTINGS.bichon_smtp_auth_required, + }; + + let smtp_shutdown = SIGNAL_MANAGER.subscribe(); + + let smtp_handle = if matches!(SETTINGS.bichon_smtp_encryption, SmtpEncryptionMode::Tls) { + let acceptor = tls_acceptor + .clone() + .expect("TLS acceptor required when tls=true"); + tokio::spawn(async move { + run_smtps_server(smtp_listener, smtp_config, acceptor, smtp_shutdown).await; + }) + } else { + tokio::spawn(async move { + run_smtp_server(smtp_listener, smtp_config, smtp_shutdown).await; + }) + }; + + Ok(SmtpServer { + smtp_addr, + smtp_handle, + }) +} diff --git a/src/modules/smtp/server.rs b/src/modules/smtp/server.rs new file mode 100644 index 0000000..3df6b11 --- /dev/null +++ b/src/modules/smtp/server.rs @@ -0,0 +1,667 @@ +// +// Copyright (c) 2025 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::io; +use std::time::Duration; + +use crate::modules::cache::imap::mailbox::{Attribute, AttributeEnum}; +use crate::modules::envelope::extractor::extract_envelope_from_smtp; +use crate::modules::error::BichonResult; +use crate::modules::indexer::manager::EML_INDEX_MANAGER; +use crate::modules::indexer::manager::ENVELOPE_INDEX_MANAGER; +use crate::modules::indexer::schema::SchemaTools; +use crate::modules::utils::create_hash; +use crate::modules::{ + account::migration::AccountModel, + cache::imap::mailbox::MailBox, + common::auth::ClientContext, + smtp::{stream::BufStream, SmtpConfig}, + token::AccessTokenModel, + users::{permissions::Permission, UserModel}, +}; +use base64::{prelude::BASE64_STANDARD, Engine as _}; +use tantivy::doc; +use tokio::time::timeout; +use tokio::{ + io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt}, + net::{TcpListener, TcpStream}, + sync::broadcast, +}; +use tokio_rustls::TlsAcceptor; + +const MAX_MAIL_SIZE: usize = 50 * 1024 * 1024; //50MB +const SMTP_IDLE_TIMEOUT: Duration = Duration::from_secs(60); +const GLOBAL_SESSION_TIMEOUT: Duration = Duration::from_secs(600); + +pub async fn run_smtp_server( + listener: TcpListener, + config: SmtpConfig, + mut shutdown: broadcast::Receiver<()>, +) { + loop { + tokio::select! { + result = listener.accept() => { + match result { + Ok((stream, addr)) => { + tracing::debug!("SMTP connection from {addr}"); + let config = config.clone(); + tokio::spawn(async move { + let res = timeout(GLOBAL_SESSION_TIMEOUT, handle_connection(stream, config)).await; + match res { + Ok(Ok(_)) => tracing::debug!("SMTP session from {addr} finished"), + Ok(Err(e)) => tracing::debug!("SMTP session error from {addr}: {e}"), + Err(_) => tracing::warn!("SMTP session from {addr} timed out after {}s", GLOBAL_SESSION_TIMEOUT.as_secs()), + } + }); + } + Err(e) => { + tracing::error!("Failed to accept connection: {e}"); + } + } + } + _ = shutdown.recv() => { + break; + } + } + } +} + +pub async fn run_smtps_server( + listener: TcpListener, + config: SmtpConfig, + tls_acceptor: TlsAcceptor, + mut shutdown: broadcast::Receiver<()>, +) { + loop { + tokio::select! { + result = listener.accept() => { + match result { + Ok((stream, addr)) => { + tracing::debug!("SMTPS connection from {addr}"); + let config = config.clone(); + let acceptor = tls_acceptor.clone(); + tokio::spawn(async move { + match acceptor.accept(stream).await { + Ok(tls_stream) => { + let res = timeout(GLOBAL_SESSION_TIMEOUT, handle_tls_connection(tls_stream, config)).await; + match res { + Ok(Ok(_)) => tracing::debug!("SMTPS session from {addr} finished"), + Ok(Err(e)) => tracing::debug!("SMTPS session error from {addr}: {e}"), + Err(_) => tracing::warn!("SMTPS session from {addr} timed out after {}s", GLOBAL_SESSION_TIMEOUT.as_secs()), + } + } + Err(e) => { + tracing::debug!("TLS handshake failed: {e}"); + } + } + }); + } + Err(e) => { + tracing::error!("Failed to accept connection: {e}"); + } + } + } + _ = shutdown.recv() => { + break; + } + } + } +} + +enum CommandResult { + Continue, + Quit, + StartTls, +} + +struct Session { + mail_from: Option, + rcpt_to: Vec, + authenticated: bool, + user: Option, + auth_required: bool, + tls_active: bool, + auth_state: AuthState, +} + +impl Session { + const fn new(auth_required: bool, tls_active: bool) -> Self { + Self { + mail_from: None, + rcpt_to: Vec::new(), + authenticated: false, + user: None, + auth_required, + tls_active, + auth_state: AuthState::None, + } + } + + fn reset(&mut self) { + self.mail_from = None; + self.rcpt_to.clear(); + } +} + +#[derive(Default)] +enum AuthState { + #[default] + None, + WaitingForPlain, + WaitingForLoginUsername, + WaitingForLoginPassword(String), +} + +/// Handle a plain TCP connection with optional STARTTLS upgrade. +async fn handle_connection(stream: TcpStream, config: SmtpConfig) -> io::Result<()> { + let mut session = Session::new(config.auth_required, false); + + // Use buffered I/O over the raw stream + let mut stream = BufStream::new(stream); + + stream + .write_all(b"220 localhost ESMTP (Bichon Email Archiver)\r\n") + .await?; + stream.flush().await?; + + loop { + match process_command(&mut stream, &mut session, &config).await? { + CommandResult::Continue => {} + CommandResult::Quit => break, + CommandResult::StartTls => { + if let Some(ref acceptor) = config.tls_acceptor { + tracing::debug!("Upgrading connection to TLS"); + let inner = stream.into_inner(); + match acceptor.clone().accept(inner).await { + Ok(tls_stream) => { + session.tls_active = true; + session.reset(); + return handle_tls_session(tls_stream, session, config).await; + } + Err(e) => { + tracing::debug!("STARTTLS handshake failed: {e}"); + return Err(io::Error::other(format!("TLS handshake failed: {e}"))); + } + } + } + } + } + } + + Ok(()) +} + +async fn handle_tls_connection(stream: S, config: SmtpConfig) -> io::Result<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let session = Session::new(config.auth_required, true); + handle_tls_session(stream, session, config).await +} + +async fn handle_tls_session( + stream: S, + mut session: Session, + config: SmtpConfig, +) -> io::Result<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let mut stream = BufStream::new(stream); + loop { + match process_command(&mut stream, &mut session, &config).await? { + CommandResult::Continue => {} + CommandResult::Quit => break, + CommandResult::StartTls => { + stream.write_all(b"503 TLS already active\r\n").await?; + stream.flush().await?; + } + } + } + + Ok(()) +} + +#[allow(clippy::too_many_lines)] +async fn process_command( + stream: &mut BufStream, + session: &mut Session, + config: &SmtpConfig, +) -> io::Result +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let mut line = String::new(); + let bytes_read = match timeout(SMTP_IDLE_TIMEOUT, stream.inner.read_line(&mut line)).await { + Ok(res) => res?, + Err(_) => return Err(io::Error::new(io::ErrorKind::TimedOut, "Command timeout")), + }; + + if bytes_read == 0 { + return Ok(CommandResult::Quit); + } + + let trimmed = line.trim(); + let cmd = trimmed.to_uppercase(); + + match &session.auth_state { + AuthState::WaitingForPlain => { + verify_plain_auth(trimmed, session, stream).await?; + session.auth_state = AuthState::None; + return Ok(CommandResult::Continue); + } + AuthState::WaitingForLoginUsername => { + if let Ok(decoded) = BASE64_STANDARD.decode(trimmed) { + let username = String::from_utf8_lossy(&decoded).to_string(); + stream.write_all(b"334 UGFzc3dvcmQ6\r\n").await?; + stream.flush().await?; + session.auth_state = AuthState::WaitingForLoginPassword(username); + } else { + stream.write_all(b"501 Cannot decode\r\n").await?; + stream.flush().await?; + session.auth_state = AuthState::None; + } + return Ok(CommandResult::Continue); + } + AuthState::WaitingForLoginPassword(username) => { + let username = username.clone(); + if let Ok(decoded) = BASE64_STANDARD.decode(trimmed) { + let password = String::from_utf8_lossy(&decoded); + match AccessTokenModel::resolve_user_from_token(&password).await { + Ok(user) => { + session.authenticated = true; + session.user = Some(user); + stream + .write_all(b"235 Authentication successful\r\n") + .await?; + } + Err(error) => { + tracing::error!( + "SMTP Auth failed for user '{}' (AUTH LOGIN): {:?}", + username, + error + ); + stream.write_all(b"535 Authentication failed\r\n").await?; + } + } + } else { + stream.write_all(b"501 Cannot decode\r\n").await?; + } + stream.flush().await?; + session.auth_state = AuthState::None; + return Ok(CommandResult::Continue); + } + AuthState::None => {} + } + + if cmd.starts_with("EHLO") || cmd.starts_with("HELO") { + let mut response = String::from("250-Bichon Hello\r\n"); + response.push_str("250-SIZE 52428800\r\n"); // 50MB + response.push_str("250-8BITMIME\r\n"); + + if config.tls_acceptor.is_some() && !session.tls_active { + response.push_str("250-STARTTLS\r\n"); + } + + response.push_str("250-AUTH PLAIN LOGIN\r\n"); + response.push_str("250 OK\r\n"); + + stream.write_all(response.as_bytes()).await?; + stream.flush().await?; + } else if cmd.starts_with("STARTTLS") { + if config.tls_acceptor.is_none() { + stream.write_all(b"454 TLS not available\r\n").await?; + } else if session.tls_active { + stream.write_all(b"503 TLS already active\r\n").await?; + } else { + stream.write_all(b"220 Ready to start TLS\r\n").await?; + stream.flush().await?; + return Ok(CommandResult::StartTls); + } + } else if cmd.starts_with("AUTH ") { + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.len() >= 2 { + let mechanism = parts[1].to_uppercase(); + match mechanism.as_str() { + "PLAIN" => { + if parts.len() > 2 { + verify_plain_auth(parts[2], session, stream).await?; + } else { + stream.write_all(b"334 \r\n").await?; + session.auth_state = AuthState::WaitingForPlain; + } + } + "LOGIN" => { + stream.write_all(b"334 VXNlcm5hbWU6\r\n").await?; + session.auth_state = AuthState::WaitingForLoginUsername; + } + _ => { + stream.write_all(b"504 Unrecognized auth type\r\n").await?; + } + } + } else { + stream.write_all(b"501 Syntax error\r\n").await?; + } + } else if cmd.starts_with("MAIL FROM:") { + if session.auth_required && !session.authenticated { + stream.write_all(b"530 Authentication required\r\n").await?; + } else { + let addr = extract_address(&trimmed[10..]); + let mut allowed = true; + if let Some(ref whitelist) = config.whitelist { + if !whitelist.is_empty() && !whitelist.contains(&addr) { + allowed = false; + } + } + + if allowed { + session.mail_from = Some(addr); + stream.write_all(b"250 OK\r\n").await?; + } else { + stream.write_all(b"550 Sender not allowed\r\n").await?; + } + } + } else if cmd.starts_with("RCPT TO:") { + if !session.rcpt_to.is_empty() { + stream + .write_all(b"452 4.5.3 Too many recipients, try again in a new transaction\r\n") + .await?; + return Ok(CommandResult::Continue); + } + + if session.auth_required && !session.authenticated { + stream.write_all(b"530 Authentication required\r\n").await?; + } else if session.mail_from.is_none() { + stream + .write_all(b"503 MAIL FROM required first\r\n") + .await?; + } else { + let addr = extract_address(&trimmed[8..]); + println!("DEBUG: SMTP RCPT TO extracted address -> '{}'", addr); + let account_result = AccountModel::find_by_email(addr.as_str()).await; + + match account_result { + Ok(Some(account)) => { + let mut is_allowed = true; + if session.auth_required { + if let Some(user) = &session.user { + let has_perm = ClientContext::check_has_permission( + user, + Some(account.id), + Permission::DATA_SMTP_INGEST, + ) + .await; + + if !has_perm { + tracing::warn!( + "SMTP: Access denied for User {} to Account <{}>", + user.id, + addr + ); + stream + .write_all( + b"554 5.7.1 Access denied: Insufficient permissions\r\n", + ) + .await?; + is_allowed = false; + } + } else { + stream + .write_all(b"530 5.7.0 Authentication required\r\n") + .await?; + is_allowed = false; + } + } + + if is_allowed { + session.rcpt_to.push(account); + stream.write_all(b"250 OK\r\n").await?; + } + } + Ok(None) => { + let err = format!("550 5.1.1 <{}>: Bichon account not found\r\n", addr); + stream.write_all(err.as_bytes()).await?; + } + Err(e) => { + tracing::error!("SMTP: Account query error for {}: {:?}", addr, e); + stream + .write_all( + b"451 4.3.0 Requested action aborted: local error in processing\r\n", + ) + .await?; + } + } + } + } else if cmd == "DATA" { + if session.auth_required && !session.authenticated { + stream.write_all(b"530 Authentication required\r\n").await?; + } else if session.mail_from.is_none() { + stream + .write_all(b"503 MAIL FROM required first\r\n") + .await?; + } else if session.rcpt_to.is_empty() { + stream.write_all(b"503 RCPT TO required first\r\n").await?; + } else { + stream + .write_all(b"354 End data with .\r\n") + .await?; + stream.flush().await?; + + let data = match read_data(&mut stream.inner).await { + Ok(d) => d, + Err(e) => { + if e.to_string().contains("552") { + let error_msg = format!( + "552 5.3.4 Message size exceeds limit of {} bytes ({}MB)\r\n", + MAX_MAIL_SIZE, + MAX_MAIL_SIZE / 1024 / 1024 + ); + stream.write_all(error_msg.as_bytes()).await?; + stream.flush().await?; + return Ok(CommandResult::Continue); + } + return Err(e); + } + }; + match parse_email(&data, session).await { + Ok(_) => { + stream + .write_all(b"250 2.0.0 OK: queued in Bichon\r\n") + .await?; + tracing::info!( + "SMTP: Message accepted and archived for {} recipients", + session.rcpt_to.len() + ); + session.reset(); + } + Err(e) => { + tracing::error!("SMTP: Critical error during parse_email: {:?}", e); + stream + .write_all( + b"451 4.3.0 Error: local error in processing, try again later\r\n", + ) + .await?; + } + } + } + } else if cmd == "RSET" { + session.reset(); + stream.write_all(b"250 OK\r\n").await?; + } else if cmd == "NOOP" { + stream.write_all(b"250 OK\r\n").await?; + } else if cmd == "QUIT" { + stream.write_all(b"221 Bye\r\n").await?; + stream.flush().await?; + return Ok(CommandResult::Quit); + } else { + stream.write_all(b"500 Command not recognized\r\n").await?; + } + + stream.flush().await?; + Ok(CommandResult::Continue) +} + +async fn verify_plain_auth( + encoded: &str, + session: &mut Session, + stream: &mut BufStream, +) -> io::Result<()> { + if let Ok(decoded) = BASE64_STANDARD.decode(encoded.trim()) { + let parts: Vec<&[u8]> = decoded.split(|&b| b == 0).collect(); + if parts.len() >= 3 { + let username = String::from_utf8_lossy(parts[1]); + let password = String::from_utf8_lossy(parts[2]); + + match AccessTokenModel::resolve_user_from_token(&password).await { + Ok(user) => { + session.authenticated = true; + session.user = Some(user); + stream + .write_all(b"235 Authentication successful\r\n") + .await?; + stream.flush().await?; + return Ok(()); + } + Err(error) => { + tracing::error!("SMTP Auth failed for user '{}': {:?}", username, error); + } + } + } + } + + stream.write_all(b"535 Authentication failed\r\n").await?; + stream.flush().await?; + Ok(()) +} + +fn extract_address(s: &str) -> String { + let s = s.trim(); + if let (Some(start), Some(end)) = (s.find('<'), s.find('>')) { + return s[start + 1..end].to_string(); + } + s.to_string() +} + +async fn read_data(reader: &mut R) -> io::Result> { + let mut data = Vec::with_capacity(65536); + let mut line = String::new(); + let mut total_bytes = 0; + let line_timeout = Duration::from_secs(30); + + loop { + line.clear(); + let bytes_read = match timeout(line_timeout, reader.read_line(&mut line)).await { + Ok(res) => res?, + Err(_) => { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "Data transmission timeout", + )) + } + }; + + if bytes_read == 0 { + break; + } + total_bytes += bytes_read; + + if total_bytes > MAX_MAIL_SIZE { + tracing::warn!( + "SMTP: Message rejected. Size {} bytes exceeds limit of {}MB", + total_bytes, + MAX_MAIL_SIZE / 1024 / 1024 + ); + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "552 5.3.4 Message size exceeds fixed maximum message size", + )); + } + if line.trim() == "." { + break; + } + let content = if line.starts_with("..") { + &line[1..] + } else { + &line + }; + data.extend_from_slice(content.as_bytes()); + } + + Ok(data) +} + +async fn parse_email(data: &[u8], session: &Session) -> BichonResult<()> { + let fields = SchemaTools::eml_fields(); + let rcpt = match session.rcpt_to.first() { + Some(r) => r, + None => { + tracing::warn!( + "SMTP: parse_email called with empty recipient list. Skipping processing." + ); + return Ok(()); + } + }; + let mailbox = MailBox { + id: create_hash(rcpt.id, "INBOX"), + account_id: rcpt.id, + name: "INBOX".into(), + delimiter: Some("/".to_string()), + attributes: vec![Attribute { + attr: AttributeEnum::Extension, + extension: Some("CreatedByBichon".into()), + }], + exists: 0, + unseen: None, + uid_next: None, + uid_validity: None, + }; + let mailbox_id = mailbox.id; + + if let Err(e) = MailBox::batch_upsert(&[mailbox]).await { + tracing::error!("SMTP: Failed to upsert mailbox for {}: {:?}", rcpt.email, e); + return Err(e.into()); + } + + let envelope = extract_envelope_from_smtp(data, rcpt.id, mailbox_id).map_err(|e| { + tracing::error!( + "SMTP: Envelope extraction failed for {}: {:?}", + rcpt.email, + e + ); + e + })?; + + let eml_id = create_hash(rcpt.id, &envelope.0.message_id); + ENVELOPE_INDEX_MANAGER + .add_document(envelope.0.id, envelope) + .await; + + EML_INDEX_MANAGER + .add_document( + eml_id, + doc!( + fields.f_id => eml_id, + fields.f_account_id => rcpt.id, + fields.f_mailbox_id => mailbox_id, + fields.f_eml => data + ), + ) + .await; + + Ok(()) +} diff --git a/src/modules/smtp/stream.rs b/src/modules/smtp/stream.rs new file mode 100644 index 0000000..315c38e --- /dev/null +++ b/src/modules/smtp/stream.rs @@ -0,0 +1,83 @@ +// +// Copyright (c) 2025 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::{io, pin::Pin}; +use tokio::io::{AsyncBufRead, AsyncRead, AsyncWrite, BufReader}; + +pub struct BufStream { + pub inner: BufReader, +} + +impl BufStream { + pub fn new(stream: S) -> Self { + Self { + inner: BufReader::new(stream), + } + } + + pub fn into_inner(self) -> S { + self.inner.into_inner() + } +} + +impl AsyncBufRead for BufStream { + fn poll_fill_buf( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + Pin::new(&mut self.get_mut().inner).poll_fill_buf(cx) + } + + fn consume(self: Pin<&mut Self>, amt: usize) { + Pin::new(&mut self.get_mut().inner).consume(amt); + } +} + +impl AsyncRead for BufStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + Pin::new(&mut self.get_mut().inner).poll_read(cx, buf) + } +} + +impl AsyncWrite for BufStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + Pin::new(self.get_mut().inner.get_mut()).poll_write(cx, buf) + } + + fn poll_flush( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + Pin::new(self.get_mut().inner.get_mut()).poll_flush(cx) + } + + fn poll_shutdown( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + Pin::new(self.get_mut().inner.get_mut()).poll_shutdown(cx) + } +} diff --git a/src/modules/smtp/tests.rs b/src/modules/smtp/tests.rs new file mode 100644 index 0000000..2f8cd64 --- /dev/null +++ b/src/modules/smtp/tests.rs @@ -0,0 +1,91 @@ +// +// Copyright (c) 2025 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +use std::error::Error; + +use lettre::address::Envelope; +use lettre::transport::smtp::authentication::{Credentials, Mechanism}; +use lettre::transport::smtp::client::{Tls, TlsParameters}; +use lettre::{Message, SmtpTransport, Transport}; + +#[tokio::test] +async fn test_smtp_archiving_flow() { + let email = Message::builder() + .from("tester@bichon.local".parse().unwrap()) + .to("archive@bichon.local".parse().unwrap()) + .subject("Integration Test") + .body(String::from("Checking if Bichon saves this!")) + .unwrap(); + + let envelope = Envelope::new( + Some("sender@example.com".parse().unwrap()), + vec!["placeholder@example.com".parse().unwrap()], // the email of a bichon account + ) + .unwrap(); + let mailer = SmtpTransport::builder_dangerous("127.0.0.1") + .port(2525) + .tls(Tls::None) + .build(); + + let result = mailer.send_raw(&envelope, &email.formatted()); + assert!( + result.is_ok(), + "SMTP delivery should succeed, got: {:?}", + result.err() + ); +} + +#[test] +fn test_bichon_smtp_logic() -> Result<(), Box> { + let smtp_host = "127.0.0.1"; + let smtp_port = 2525; + + println!("--- Testing STARTTLS Upgrade ---"); + + let tls_parameters = TlsParameters::builder(smtp_host.to_string()) + .dangerous_accept_invalid_certs(true) + .build()?; + + let mailer = SmtpTransport::starttls_relay(smtp_host)? + .port(smtp_port) + .tls(Tls::Required(tls_parameters)) + .authentication(vec![Mechanism::Login, Mechanism::Plain]) + .credentials(Credentials::new( + "test_user".to_string(), + "hP1Z4ZBs4IjdXtjbImFoX9kM".to_string(), + )) + .build(); + + // If RCPT TO is not explicitly specified in the envelope, the addresses in the 'To' header + // will be treated as envelope recipients. Bichon enforces a single-recipient policy per + // transaction; if multiple recipients are detected, it will reject with: + // "452 4.5.3 Too many recipients, try again in a new transaction". + let email = Message::builder() + .from("sender@bichon.com".parse()?) + .to("placeholder@example.com".parse()?) + .subject("TLS Test") + .body(String::from("Hello Bichon with TLS!"))?; + + let result = mailer.send(&email); + assert!( + result.is_ok(), + "STARTTLS encryption or Auth failed: {:?}", + result.err() + ); + println!("SUCCESS: TLS upgrade and mail delivery worked."); + Ok(()) +} diff --git a/src/modules/smtp/tls.rs b/src/modules/smtp/tls.rs new file mode 100644 index 0000000..044e3b1 --- /dev/null +++ b/src/modules/smtp/tls.rs @@ -0,0 +1,80 @@ +// +// Copyright (c) 2025 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use rcgen::generate_simple_self_signed; +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use rustls::ServerConfig; +use std::io::{self, BufReader, Error, ErrorKind}; +use std::sync::Arc; +use tokio::fs::File; +use tokio::io::AsyncReadExt; +use tokio_rustls::TlsAcceptor; + +use crate::modules::settings::cli::SETTINGS; + +pub async fn create_acceptor() -> io::Result { + let (certs, key) = if let (Some(key_path), Some(cert_path)) = ( + &SETTINGS.bichon_smtp_tls_key_path, + &SETTINGS.bichon_smtp_tls_cert_path, + ) { + load_certs_from_files(key_path, cert_path).await? + } else { + generate_self_signed()? + }; + + let config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key) + .map_err(|e| Error::new(ErrorKind::InvalidData, e))?; + + Ok(TlsAcceptor::from(Arc::new(config))) +} + +async fn load_certs_from_files( + key_path: &str, + cert_path: &str, +) -> io::Result<(Vec>, PrivateKeyDer<'static>)> { + let mut key_file = File::open(key_path).await?; + let mut key_data = Vec::new(); + key_file.read_to_end(&mut key_data).await?; + + let mut cert_file = File::open(cert_path).await?; + let mut cert_data = Vec::new(); + cert_file.read_to_end(&mut cert_data).await?; + + let certs: Vec> = + rustls_pemfile::certs(&mut BufReader::new(cert_data.as_slice())) + .filter_map(Result::ok) + .collect(); + + let key = rustls_pemfile::private_key(&mut BufReader::new(key_data.as_slice()))? + .ok_or_else(|| Error::new(ErrorKind::InvalidData, "No private key found"))?; + + Ok((certs, key)) +} + +fn generate_self_signed() -> io::Result<(Vec>, PrivateKeyDer<'static>)> { + let subject_alt_names = vec!["localhost".to_string(), "127.0.0.1".to_string()]; + let key = generate_simple_self_signed(subject_alt_names).map_err(Error::other)?; + + let cert_der = CertificateDer::from(key.cert.der().to_vec()); + let key_der = PrivateKeyDer::try_from(key.signing_key.serialize_der()) + .map_err(|e| Error::new(ErrorKind::InvalidData, e))?; + + Ok((vec![cert_der], key_der)) +} diff --git a/src/modules/users/permissions.rs b/src/modules/users/permissions.rs index 9a48ce7..1d6bc9e 100644 --- a/src/modules/users/permissions.rs +++ b/src/modules/users/permissions.rs @@ -116,6 +116,9 @@ impl Permission { /// Authorization requires checking access to the target account_id. pub const DATA_IMPORT_BATCH: &str = "data:import:batch"; + /// Allow SMTP ingestion into SPECIFIC accounts. + pub const DATA_SMTP_INGEST: &str = "data:smtp:ingest"; + pub fn global_permissions() -> Vec<(&'static str, &'static str)> { vec![ ( @@ -194,6 +197,10 @@ impl Permission { Self::DATA_IMPORT_BATCH, "Import external EML/PST data into authorized accounts.", ), + ( + Self::DATA_SMTP_INGEST, + "Receive and archive emails via SMTP for authorized accounts.", + ), ] } diff --git a/src/modules/users/role.rs b/src/modules/users/role.rs index 7d6ea3b..be48dc9 100644 --- a/src/modules/users/role.rs +++ b/src/modules/users/role.rs @@ -141,6 +141,7 @@ impl BuiltinRole { Permission::DATA_DELETE, Permission::DATA_EXPORT_BATCH, Permission::DATA_IMPORT_BATCH, + Permission::DATA_SMTP_INGEST, ] .into_iter() .collect() diff --git a/web/src/api/users/api.ts b/web/src/api/users/api.ts index 5069cfa..c74c14e 100644 --- a/web/src/api/users/api.ts +++ b/web/src/api/users/api.ts @@ -40,6 +40,7 @@ export function getPermissions(t: (key: string) => string) { { label: t('permission.data.delete'), value: 'data:delete' }, { label: t('permission.data.export_batch'), value: 'data:export:batch' }, { label: t('permission.data.import_batch'), value: 'data:import:batch' }, + { label: t('permission.data.smtp_ingest'), value: 'data:smtp:ingest' }, ] } diff --git a/web/src/features/settings/profile/permissions-dialog.tsx b/web/src/features/settings/profile/permissions-dialog.tsx index ffa8dfe..23686a3 100644 --- a/web/src/features/settings/profile/permissions-dialog.tsx +++ b/web/src/features/settings/profile/permissions-dialog.tsx @@ -80,6 +80,7 @@ function getAccountCategories(t: (key: string) => string) { 'data:delete', 'data:export:batch', 'data:import:batch', + 'data:smtp:ingest', ], }, ] diff --git a/web/src/features/users/roles/components/action-dialog.tsx b/web/src/features/users/roles/components/action-dialog.tsx index 65a240f..3686146 100644 --- a/web/src/features/users/roles/components/action-dialog.tsx +++ b/web/src/features/users/roles/components/action-dialog.tsx @@ -86,6 +86,7 @@ const CATEGORY_MAP: Record<'Global' | 'Account', { titleKey: string; keys: strin 'data:delete', 'data:export:batch', 'data:import:batch', + 'data:smtp:ingest', ], }, ], diff --git a/web/src/features/users/roles/components/permissions-dialog.tsx b/web/src/features/users/roles/components/permissions-dialog.tsx index 4d14ca5..0d86a94 100644 --- a/web/src/features/users/roles/components/permissions-dialog.tsx +++ b/web/src/features/users/roles/components/permissions-dialog.tsx @@ -58,7 +58,7 @@ export function PermissionsDialog({ currentRow, open, onOpenChange }: Props) { account: [ { titleKey: "roles.categories.account_resource", - keys: ["account:manage", "account:read_details", "data:read", "data:manage", "data:raw:download", "data:delete", "data:export:batch", "data:import:batch"] + keys: ["account:manage", "account:read_details", "data:read", "data:manage", "data:raw:download", "data:delete", "data:export:batch", "data:import:batch", "data:smtp:ingest"] } ] }; diff --git a/web/src/locales/ar.json b/web/src/locales/ar.json index b6d4713..56b49db 100644 --- a/web/src/locales/ar.json +++ b/web/src/locales/ar.json @@ -1132,7 +1132,8 @@ "delete_all": "حذف رسائل البريد الإلكتروني من جميع الحسابات", "export_batch": "تصدير دفعي للبيانات المخصصة", "export_batch_all": "تصدير دفعي لجميع البيانات", - "import_batch": "استيراد دفعي لبيانات البريد الإلكتروني" + "import_batch": "استيراد دفعي لبيانات البريد الإلكتروني", + "smtp_ingest": "استيعاب رسائل البريد الإلكتروني عبر بروتوكول SMTP" } }, "users": { diff --git a/web/src/locales/da.json b/web/src/locales/da.json index d5affa4..d6e7e1a 100644 --- a/web/src/locales/da.json +++ b/web/src/locales/da.json @@ -1132,7 +1132,8 @@ "delete_all": "Slette e-mails fra alle konti", "export_batch": "Batch-eksportere tildelte data", "export_batch_all": "Batch-eksportere alle data", - "import_batch": "Batch-importere e-maildata" + "import_batch": "Batch-importere e-maildata", + "smtp_ingest": "Indsend e-mails via SMTP-protokol" } }, "users": { diff --git a/web/src/locales/de.json b/web/src/locales/de.json index dae6dfb..645d055 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -1132,7 +1132,8 @@ "delete_all": "E-Mails aller Konten löschen", "export_batch": "Zugewiesene Daten stapelweise exportieren", "export_batch_all": "Alle Daten stapelweise exportieren", - "import_batch": "E-Mail-Daten stapelweise importieren" + "import_batch": "E-Mail-Daten stapelweise importieren", + "smtp_ingest": "E-Mails über das SMTP-Protokoll aufnehmen" } }, "users": { diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 26ba374..26c20f3 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -1132,7 +1132,8 @@ "delete_all": "Delete Emails from All Accounts", "export_batch": "Batch Export Assigned Data", "export_batch_all": "Batch Export All Data", - "import_batch": "Batch Import Email Data" + "import_batch": "Batch Import Email Data", + "smtp_ingest": "Ingest Emails via SMTP Protocol" } }, "users": { diff --git a/web/src/locales/es.json b/web/src/locales/es.json index 9316c39..87dc298 100644 --- a/web/src/locales/es.json +++ b/web/src/locales/es.json @@ -1132,7 +1132,8 @@ "delete_all": "Eliminar correos de todas las cuentas", "export_batch": "Exportar datos asignados por lotes", "export_batch_all": "Exportar todos los datos por lotes", - "import_batch": "Importar datos de correo por lotes" + "import_batch": "Importar datos de correo por lotes", + "smtp_ingest": "Ingerir correos electrónicos a través del protocolo SMTP" } }, "users": { diff --git a/web/src/locales/fi.json b/web/src/locales/fi.json index 3a3c601..3aec748 100644 --- a/web/src/locales/fi.json +++ b/web/src/locales/fi.json @@ -1132,7 +1132,8 @@ "delete_all": "Kaikkien tilien sähköpostien poistaminen", "export_batch": "Tietojen erävienti", "export_batch_all": "Kaikkien tietojen erävienti", - "import_batch": "Sähköpostitietojen erätuonti" + "import_batch": "Sähköpostitietojen erätuonti", + "smtp_ingest": "Vastaanota sähköposteja SMTP-protokollan kautta" } }, "users": { diff --git a/web/src/locales/fr.json b/web/src/locales/fr.json index b6580d5..22cb3e3 100644 --- a/web/src/locales/fr.json +++ b/web/src/locales/fr.json @@ -1132,7 +1132,8 @@ "delete_all": "Supprimer les e-mails de tous les comptes", "export_batch": "Exporter les données attribuées par lot", "export_batch_all": "Exporter toutes les données par lot", - "import_batch": "Importer des données e-mail par lot" + "import_batch": "Importer des données e-mail par lot", + "smtp_ingest": "Ingérer des e-mails via le protocole SMTP" } }, "users": { diff --git a/web/src/locales/it.json b/web/src/locales/it.json index 60e30ee..0266eb4 100644 --- a/web/src/locales/it.json +++ b/web/src/locales/it.json @@ -1132,7 +1132,8 @@ "delete_all": "Eliminare le email di tutti gli account", "export_batch": "Esportare i dati assegnati in batch", "export_batch_all": "Esportare tutti i dati in batch", - "import_batch": "Importare dati email in batch" + "import_batch": "Importare dati email in batch", + "smtp_ingest": "Acquisire e-mail tramite protocollo SMTP" } }, "users": { diff --git a/web/src/locales/jp.json b/web/src/locales/jp.json index bf10dfb..ed55812 100644 --- a/web/src/locales/jp.json +++ b/web/src/locales/jp.json @@ -1132,7 +1132,8 @@ "delete_all": "すべてのアカウントのメールを削除", "export_batch": "割り当てデータを一括エクスポート", "export_batch_all": "すべてのデータを一括エクスポート", - "import_batch": "メールデータを一括インポート" + "import_batch": "メールデータを一括インポート", + "smtp_ingest": "SMTPプロトコル経由でメールを取り込む" } }, "users": { diff --git a/web/src/locales/ko.json b/web/src/locales/ko.json index 1db1474..9ac554b 100644 --- a/web/src/locales/ko.json +++ b/web/src/locales/ko.json @@ -1132,7 +1132,8 @@ "delete_all": "모든 계정의 이메일 삭제", "export_batch": "할당 데이터 일괄 내보내기", "export_batch_all": "모든 데이터 일괄 내보내기", - "import_batch": "이메일 데이터 일괄 가져오기" + "import_batch": "이메일 데이터 일괄 가져오기", + "smtp_ingest": "SMTP 프로토콜을 통해 이메일 수집" } }, "users": { diff --git a/web/src/locales/nl.json b/web/src/locales/nl.json index a82260b..b33ccf8 100644 --- a/web/src/locales/nl.json +++ b/web/src/locales/nl.json @@ -1132,7 +1132,8 @@ "delete_all": "E-mails van alle accounts verwijderen", "export_batch": "Toegewezen gegevens batchgewijs exporteren", "export_batch_all": "Alle gegevens batchgewijs exporteren", - "import_batch": "E-mailgegevens batchgewijs importeren" + "import_batch": "E-mailgegevens batchgewijs importeren", + "smtp_ingest": "E-mails opnemen via het SMTP-protocol" } }, "users": { diff --git a/web/src/locales/no.json b/web/src/locales/no.json index c1e7af5..6e3e1cb 100644 --- a/web/src/locales/no.json +++ b/web/src/locales/no.json @@ -1132,7 +1132,8 @@ "delete_all": "Slette e-poster fra alle kontoer", "export_batch": "Batch-eksportere tildelte data", "export_batch_all": "Batch-eksportere alle data", - "import_batch": "Batch-importere e-postdata" + "import_batch": "Batch-importere e-postdata", + "smtp_ingest": "Hent e-post via SMTP-protokoll" } }, "users": { diff --git a/web/src/locales/pl.json b/web/src/locales/pl.json index e5523d9..324d59f 100644 --- a/web/src/locales/pl.json +++ b/web/src/locales/pl.json @@ -1132,7 +1132,8 @@ "delete_all": "Usuwanie wiadomości e-mail ze wszystkich kont", "export_batch": "Eksport zbiorczy przypisanych danych", "export_batch_all": "Eksport zbiorczy wszystkich danych", - "import_batch": "Import zbiorczy danych e-mail" + "import_batch": "Import zbiorczy danych e-mail", + "smtp_ingest": "Pobieranie wiadomości e-mail za pośrednictwem protokołu SMTP" } }, "users": { diff --git a/web/src/locales/pt.json b/web/src/locales/pt.json index 4e9faa5..9994c16 100644 --- a/web/src/locales/pt.json +++ b/web/src/locales/pt.json @@ -1132,7 +1132,8 @@ "delete_all": "Excluir e-mails de todas as contas", "export_batch": "Exportar dados atribuídos em lote", "export_batch_all": "Exportar todos os dados em lote", - "import_batch": "Importar dados de e-mail em lote" + "import_batch": "Importar dados de e-mail em lote", + "smtp_ingest": "Ingerir e-mails via protocolo SMTP" } }, "users": { diff --git a/web/src/locales/ru.json b/web/src/locales/ru.json index 9f3ee04..fad2898 100644 --- a/web/src/locales/ru.json +++ b/web/src/locales/ru.json @@ -1132,7 +1132,8 @@ "delete_all": "Удаление писем всех аккаунтов", "export_batch": "Пакетный экспорт назначенных данных", "export_batch_all": "Пакетный экспорт всех данных", - "import_batch": "Пакетный импорт почтовых данных" + "import_batch": "Пакетный импорт почтовых данных", + "smtp_ingest": "Прием электронной почты через протокол SMTP" } }, "users": { diff --git a/web/src/locales/sv.json b/web/src/locales/sv.json index 2285f49..2c27cf7 100644 --- a/web/src/locales/sv.json +++ b/web/src/locales/sv.json @@ -1132,7 +1132,8 @@ "delete_all": "Ta bort e-postmeddelanden från alla konton", "export_batch": "Batch-exportera tilldelad data", "export_batch_all": "Batch-exportera all data", - "import_batch": "Batch-importera e-postdata" + "import_batch": "Batch-importera e-postdata", + "smtp_ingest": "Hämta e-post via SMTP-protokoll" } }, "users": { diff --git a/web/src/locales/zh-tw.json b/web/src/locales/zh-tw.json index 348290e..3e17768 100644 --- a/web/src/locales/zh-tw.json +++ b/web/src/locales/zh-tw.json @@ -1132,7 +1132,8 @@ "delete_all": "刪除所有帳戶的郵件", "export_batch": "批次匯出指派帳戶的資料", "export_batch_all": "批次匯出所有資料", - "import_batch": "批次匯入郵件資料" + "import_batch": "批次匯入郵件資料", + "smtp_ingest": "透過 SMTP 協定歸檔郵件" } }, "users": { diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json index b631e95..79dd5fd 100644 --- a/web/src/locales/zh.json +++ b/web/src/locales/zh.json @@ -1132,7 +1132,8 @@ "delete_all": "删除所有账户的邮件", "export_batch": "批量导出分配账户的数据", "export_batch_all": "批量导出所有数据", - "import_batch": "批量导入邮件数据" + "import_batch": "批量导入邮件数据", + "smtp_ingest": "通过 SMTP 协议归档邮件" } }, "users": {