diff --git a/.config/nextest.toml b/.config/nextest.toml index 8d67243..840556a 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -68,6 +68,10 @@ test-group = "serial-env-tests" filter = "package(tranquil-signal)" test-group = "serial-env-tests" +[[profile.default.overrides]] +filter = "package(tranquil-config)" +test-group = "serial-env-tests" + [[profile.default.overrides]] filter = "binary(whole_story)" test-group = "heavy-load-tests" @@ -118,6 +122,10 @@ test-group = "serial-env-tests" filter = "package(tranquil-signal)" test-group = "serial-env-tests" +[[profile.ci.overrides]] +filter = "package(tranquil-config)" +test-group = "serial-env-tests" + [[profile.ci.overrides]] filter = "binary(whole_story)" test-group = "heavy-load-tests" diff --git a/Cargo.lock b/Cargo.lock index 9f66aa0..ce1e259 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,7 +9,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "087113bd50d9adce24850eed5d0476c7d199d532fce8fab5173650331e09033a" dependencies = [ "abnf-core", - "nom", + "nom 7.1.3", ] [[package]] @@ -18,7 +18,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c44e09c43ae1c368fb91a03a566472d0087c26cf7e1b9e8e289c14ede681dd7d" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -213,7 +213,7 @@ dependencies = [ "asn1-rs-derive", "asn1-rs-impl", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror 1.0.69", @@ -1972,7 +1972,7 @@ checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" dependencies = [ "asn1-rs", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -2216,6 +2216,22 @@ dependencies = [ "zeroize", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "embedded-io" version = "0.4.0" @@ -3780,6 +3796,37 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lettre" +version = "0.11.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7" +dependencies = [ + "async-trait", + "base64 0.22.1", + "ed25519-dalek", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "rsa", + "rustls 0.23.37", + "sha2", + "socket2 0.6.3", + "tokio", + "tokio-rustls 0.26.4", + "tracing", + "url", + "webpki-roots 1.0.6", +] + [[package]] name = "libc" version = "0.2.183" @@ -4409,6 +4456,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" @@ -4839,7 +4895,7 @@ checksum = "9114f9c1683dd09c5f4fa024c89fdad783eaae21d3d52dd23ddaaffa29ffb168" dependencies = [ "either", "fnv", - "nom", + "nom 7.1.3", "once_cell", "postcard", "quick-xml", @@ -5427,6 +5483,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" @@ -5833,7 +5895,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -6050,6 +6112,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "serde", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -7455,7 +7527,7 @@ dependencies = [ [[package]] name = "tranquil-api" -version = "0.5.7" +version = "0.6.0" dependencies = [ "anyhow", "axum", @@ -7506,7 +7578,7 @@ dependencies = [ [[package]] name = "tranquil-auth" -version = "0.5.7" +version = "0.6.0" dependencies = [ "anyhow", "base32", @@ -7529,7 +7601,7 @@ dependencies = [ [[package]] name = "tranquil-cache" -version = "0.5.7" +version = "0.6.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -7543,11 +7615,19 @@ dependencies = [ [[package]] name = "tranquil-comms" -version = "0.5.7" +version = "0.6.0" dependencies = [ "async-trait", "base64 0.22.1", + "chrono", + "ed25519-dalek", + "futures", + "hickory-resolver", + "lettre", + "rand 0.8.5", "reqwest", + "rsa", + "secrecy", "serde_json", "sqlx", "thiserror 2.0.18", @@ -7561,7 +7641,7 @@ dependencies = [ [[package]] name = "tranquil-config" -version = "0.5.7" +version = "0.6.0" dependencies = [ "confique", "serde", @@ -7569,7 +7649,7 @@ dependencies = [ [[package]] name = "tranquil-crypto" -version = "0.5.7" +version = "0.6.0" dependencies = [ "aes-gcm", "base64 0.22.1", @@ -7585,7 +7665,7 @@ dependencies = [ [[package]] name = "tranquil-db" -version = "0.5.7" +version = "0.6.0" dependencies = [ "async-trait", "chrono", @@ -7602,7 +7682,7 @@ dependencies = [ [[package]] name = "tranquil-db-traits" -version = "0.5.7" +version = "0.6.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -7618,7 +7698,7 @@ dependencies = [ [[package]] name = "tranquil-infra" -version = "0.5.7" +version = "0.6.0" dependencies = [ "async-trait", "bytes", @@ -7629,7 +7709,7 @@ dependencies = [ [[package]] name = "tranquil-lexicon" -version = "0.5.7" +version = "0.6.0" dependencies = [ "chrono", "futures", @@ -7648,7 +7728,7 @@ dependencies = [ [[package]] name = "tranquil-oauth" -version = "0.5.7" +version = "0.6.0" dependencies = [ "anyhow", "axum", @@ -7671,7 +7751,7 @@ dependencies = [ [[package]] name = "tranquil-oauth-server" -version = "0.5.7" +version = "0.6.0" dependencies = [ "axum", "base64 0.22.1", @@ -7704,7 +7784,7 @@ dependencies = [ [[package]] name = "tranquil-pds" -version = "0.5.7" +version = "0.6.0" dependencies = [ "aes-gcm", "anyhow", @@ -7796,7 +7876,7 @@ dependencies = [ [[package]] name = "tranquil-repo" -version = "0.5.7" +version = "0.6.0" dependencies = [ "bytes", "cid", @@ -7808,7 +7888,7 @@ dependencies = [ [[package]] name = "tranquil-ripple" -version = "0.5.7" +version = "0.6.0" dependencies = [ "async-trait", "backon", @@ -7833,7 +7913,7 @@ dependencies = [ [[package]] name = "tranquil-scopes" -version = "0.5.7" +version = "0.6.0" dependencies = [ "axum", "futures", @@ -7849,7 +7929,7 @@ dependencies = [ [[package]] name = "tranquil-server" -version = "0.5.7" +version = "0.6.0" dependencies = [ "axum", "clap", @@ -7870,7 +7950,7 @@ dependencies = [ [[package]] name = "tranquil-signal" -version = "0.5.7" +version = "0.6.0" dependencies = [ "async-trait", "chrono", @@ -7893,7 +7973,7 @@ dependencies = [ [[package]] name = "tranquil-storage" -version = "0.5.7" +version = "0.6.0" dependencies = [ "async-trait", "aws-config", @@ -7910,7 +7990,7 @@ dependencies = [ [[package]] name = "tranquil-store" -version = "0.5.7" +version = "0.6.0" dependencies = [ "async-trait", "bytes", @@ -7959,7 +8039,7 @@ dependencies = [ [[package]] name = "tranquil-sync" -version = "0.5.7" +version = "0.6.0" dependencies = [ "anyhow", "axum", @@ -7981,7 +8061,7 @@ dependencies = [ [[package]] name = "tranquil-types" -version = "0.5.7" +version = "0.6.0" dependencies = [ "chrono", "cid", @@ -8496,7 +8576,7 @@ dependencies = [ "base64urlsafedata", "der-parser", "hex", - "nom", + "nom 7.1.3", "openssl", "openssl-sys", "rand 0.9.2", @@ -9068,7 +9148,7 @@ dependencies = [ "data-encoding", "der-parser", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry", "rusticata-macros", "thiserror 1.0.69", diff --git a/Cargo.toml b/Cargo.toml index d4d4345..9ff5c18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ members = [ ] [workspace.package] -version = "0.5.7" +version = "0.6.0" edition = "2024" license = "AGPL-3.0-or-later" @@ -93,6 +93,7 @@ ipld-core = "0.4" iroh-car = "0.5" jacquard-common = { version = "0.9", features = ["crypto-k256"] } jacquard-repo = "0.9" +lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls-tls", "pool", "dkim", "tracing"] } jsonwebtoken = { version = "10.2", features = ["rust_crypto"] } k256 = { version = "0.13", features = ["ecdsa", "pem", "pkcs8"] } metrics = "0.24" @@ -105,6 +106,8 @@ p384 = { version = "0.13", features = ["ecdsa"] } rand = "0.8" redis = { version = "1.0", features = ["tokio-comp", "connection-manager"] } regex = "1" +rsa = "0.9" +secrecy = { version = "0.10", features = ["serde"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "http2", "charset", "macos-system-configuration"] } serde = { version = "1.0", features = ["derive"] } serde_bytes = "0.11" diff --git a/crates/tranquil-comms/Cargo.toml b/crates/tranquil-comms/Cargo.toml index 07dc277..1a6ae7b 100644 --- a/crates/tranquil-comms/Cargo.toml +++ b/crates/tranquil-comms/Cargo.toml @@ -10,7 +10,14 @@ tranquil-signal = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } +ed25519-dalek = { workspace = true } +futures = { workspace = true } +hickory-resolver = { workspace = true } +lettre = { workspace = true } +rand = { workspace = true } reqwest = { workspace = true } +rsa = { workspace = true } +secrecy = { workspace = true } serde_json = { workspace = true } sqlx = { workspace = true } thiserror = { workspace = true } @@ -18,3 +25,7 @@ tokio = { workspace = true } tracing = { workspace = true } tranquil-db-traits = { workspace = true } uuid = { workspace = true } + +[dev-dependencies] +chrono = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time", "io-util", "net"] } diff --git a/crates/tranquil-comms/src/email/types.rs b/crates/tranquil-comms/src/email/types.rs new file mode 100644 index 0000000..1c15b34 --- /dev/null +++ b/crates/tranquil-comms/src/email/types.rs @@ -0,0 +1,291 @@ +use std::path::PathBuf; + +#[derive(Debug, thiserror::Error)] +pub enum ParseError { + #[error("empty value")] + Empty, + #[error("invalid character {0:?}")] + InvalidChar(char), + #[error("zero {0}")] + Zero(&'static str), + #[error("invalid TLS mode {0:?}")] + InvalidTlsMode(String), +} + +fn parse_token(raw: &str, lowercase: bool, strip_trailing_dot: bool) -> Result { + let mut s = raw.trim(); + if strip_trailing_dot { + s = s.trim_end_matches('.'); + } + match s { + "" => Err(ParseError::Empty), + _ if s.chars().any(char::is_whitespace) => Err(ParseError::InvalidChar(' ')), + _ => Ok(match lowercase { + true => s.to_lowercase(), + false => s.to_string(), + }), + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SmtpHost(String); + +impl SmtpHost { + pub fn parse(raw: &str) -> Result { + parse_token(raw, true, false).map(Self) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SmtpPort(u16); + +impl SmtpPort { + pub fn parse(raw: u16) -> Result { + match raw { + 0 => Err(ParseError::Zero("smtp port")), + n => Ok(Self(n)), + } + } + + pub fn as_u16(self) -> u16 { + self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct HeloName(String); + +impl HeloName { + pub fn parse(raw: &str) -> Result { + parse_token(raw, false, false).map(Self) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct EmailDomain(String); + +impl EmailDomain { + pub fn parse(raw: &str) -> Result { + parse_token(raw, true, true).map(Self) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct MxHost(String); + +impl MxHost { + pub fn parse(raw: &str) -> Result { + parse_token(raw, true, true).map(Self) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct MxPriority(u16); + +impl MxPriority { + pub fn new(value: u16) -> Self { + Self(value) + } + + pub fn as_u16(self) -> u16 { + self.0 + } +} + +#[derive(Debug, Clone)] +pub struct MxRecord { + pub priority: MxPriority, + pub host: MxHost, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct DkimSelector(String); + +impl DkimSelector { + pub fn parse(raw: &str) -> Result { + let trimmed = raw.trim(); + let valid = !trimmed.is_empty() && trimmed.split('.').all(valid_subdomain); + match valid { + true => Ok(Self(trimmed.to_string())), + false => Err(ParseError::InvalidChar('?')), + } + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +fn valid_subdomain(seg: &str) -> bool { + let starts_alnum = seg + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphanumeric()); + let ends_alnum = seg + .chars() + .next_back() + .is_some_and(|c| c.is_ascii_alphanumeric()); + let body_ok = seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'); + starts_alnum && ends_alnum && body_ok +} + +#[derive(Debug, Clone)] +pub struct DkimKeyPath(PathBuf); + +impl DkimKeyPath { + pub fn parse(raw: &str) -> Result { + let trimmed = raw.trim(); + match trimmed.is_empty() { + true => Err(ParseError::Empty), + false => Ok(Self(PathBuf::from(trimmed))), + } + } + + pub fn as_path(&self) -> &std::path::Path { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmtpUsername(String); + +impl SmtpUsername { + pub fn parse(raw: &str) -> Result { + match raw.is_empty() { + true => Err(ParseError::Empty), + false => Ok(Self(raw.to_string())), + } + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +#[derive(Clone)] +pub struct SmtpPassword(secrecy::SecretString); + +impl SmtpPassword { + pub fn parse(raw: &str) -> Result { + match raw.is_empty() { + true => Err(ParseError::Empty), + false => Ok(Self(secrecy::SecretString::from(raw.to_string()))), + } + } + + pub fn expose(&self) -> &str { + use secrecy::ExposeSecret; + self.0.expose_secret() + } +} + +impl std::fmt::Debug for SmtpPassword { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SmtpPassword(***)") + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TlsMode { + Implicit, + Starttls, + None, +} + +impl TlsMode { + pub fn parse(raw: &str) -> Result { + match raw.to_ascii_lowercase().as_str() { + "implicit" => Ok(Self::Implicit), + "starttls" => Ok(Self::Starttls), + "none" => Ok(Self::None), + other => Err(ParseError::InvalidTlsMode(other.to_string())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn smtp_host_lowercases_and_trims() { + let h = SmtpHost::parse(" SMTP.NEL.PET ").unwrap(); + assert_eq!(h.as_str(), "smtp.nel.pet"); + } + + #[test] + fn smtp_host_rejects_whitespace() { + assert!(SmtpHost::parse("a b").is_err()); + } + + #[test] + fn smtp_host_rejects_empty() { + assert!(SmtpHost::parse("").is_err()); + assert!(SmtpHost::parse(" ").is_err()); + } + + #[test] + fn smtp_port_rejects_zero() { + assert!(SmtpPort::parse(0).is_err()); + assert_eq!(SmtpPort::parse(587).unwrap().as_u16(), 587); + } + + #[test] + fn email_domain_strips_trailing_dot() { + assert_eq!(EmailDomain::parse("Nel.pet.").unwrap().as_str(), "nel.pet"); + } + + #[test] + fn dkim_selector_validates() { + assert!(DkimSelector::parse("default").is_ok()); + assert!(DkimSelector::parse("s1.nel.pet").is_ok()); + assert!(DkimSelector::parse("s2024-q1").is_ok()); + assert!(DkimSelector::parse("mailo-2024.nel.pet").is_ok()); + assert!(DkimSelector::parse("a-b").is_ok()); + assert!(DkimSelector::parse("").is_err()); + assert!(DkimSelector::parse("a..b").is_err()); + assert!(DkimSelector::parse("-leading").is_err()); + assert!(DkimSelector::parse("trailing-").is_err()); + assert!(DkimSelector::parse("s_under").is_err()); + } + + #[test] + fn tls_mode_parses_known_modes() { + assert_eq!(TlsMode::parse("STARTTLS").unwrap(), TlsMode::Starttls); + assert_eq!(TlsMode::parse("implicit").unwrap(), TlsMode::Implicit); + assert_eq!(TlsMode::parse("none").unwrap(), TlsMode::None); + assert!(TlsMode::parse("garbage").is_err()); + } + + #[test] + fn smtp_password_redacts_in_debug() { + let p = SmtpPassword::parse("hunter2").unwrap(); + let dbg = format!("{:?}", p); + assert_eq!(dbg, "SmtpPassword(***)"); + assert!(!dbg.contains("hunter2")); + } +} diff --git a/crates/tranquil-config/src/lib.rs b/crates/tranquil-config/src/lib.rs index 601898a..c36302a 100644 --- a/crates/tranquil-config/src/lib.rs +++ b/crates/tranquil-config/src/lib.rs @@ -5,6 +5,14 @@ use std::sync::OnceLock; static CONFIG: OnceLock = OnceLock::new(); +const REMOVED_ENV_VARS: &[(&str, &str)] = &[( + "SENDMAIL_PATH", + "the sendmail-binary transport was replaced with native SMTP. \ + Configure MAIL_SMARTHOST_HOST for relay delivery, or leave it unset to \ + deliver directly via recipient MX records. See example.toml for the full \ + MAIL_* surface.", +)]; + /// Errors discovered during configuration validation. #[derive(Debug)] pub struct ConfigError { @@ -162,6 +170,14 @@ impl TranquilConfig { pub fn validate(&self, ignore_secrets: bool) -> Result<(), ConfigError> { let mut errors = Vec::new(); + // -- removed config --------------------------------------------------- + errors.extend( + REMOVED_ENV_VARS + .iter() + .filter(|(var, _)| std::env::var_os(var).is_some()) + .map(|(var, guidance)| format!("{var} is no longer supported: {guidance}")), + ); + // -- secrets ---------------------------------------------------------- if !ignore_secrets && !self.secrets.allow_insecure && !cfg!(test) { if let Some(ref s) = self.secrets.jwt_secret { @@ -210,6 +226,85 @@ impl TranquilConfig { } } + // -- email smarthost -------------------------------------------------- + match self.email.smarthost.tls.to_ascii_lowercase().as_str() { + "implicit" | "starttls" => {} + "none" => { + if self.email.smarthost.password.is_some() { + errors.push( + "email.smarthost.tls = \"none\" with email.smarthost.password set \ + would transmit credentials in plaintext; use \"starttls\" or \"implicit\"" + .to_string(), + ); + } + } + other => errors.push(format!( + "email.smarthost.tls must be \"implicit\", \"starttls\", or \"none\", got \"{other}\"" + )), + } + + let smarthost_host_set = self + .email + .smarthost + .host + .as_deref() + .is_some_and(|h| !h.is_empty()); + let username_set = self.email.smarthost.username.is_some(); + let password_set = self.email.smarthost.password.is_some(); + if !smarthost_host_set && (username_set || password_set) { + errors.push( + "email.smarthost.username or email.smarthost.password is set but \ + email.smarthost.host is empty; credentials would be silently ignored" + .to_string(), + ); + } + if smarthost_host_set && username_set != password_set { + errors.push( + "email.smarthost.username and email.smarthost.password must both be set or \ + both unset; otherwise authentication would silently degrade to anonymous" + .to_string(), + ); + } + + if self.email.smarthost.command_timeout_secs == 0 { + errors.push("email.smarthost.command_timeout_secs must be at least 1".to_string()); + } + if self.email.smarthost.total_timeout_secs == 0 { + errors.push("email.smarthost.total_timeout_secs must be at least 1".to_string()); + } + if self.email.smarthost.pool_size == 0 { + errors.push("email.smarthost.pool_size must be at least 1".to_string()); + } + + if self.email.direct_mx.max_concurrent_sends == 0 { + errors.push("email.direct_mx.max_concurrent_sends must be at least 1".to_string()); + } + if self.email.direct_mx.command_timeout_secs == 0 { + errors.push("email.direct_mx.command_timeout_secs must be at least 1".to_string()); + } + if self.email.direct_mx.total_timeout_secs == 0 { + errors.push("email.direct_mx.total_timeout_secs must be at least 1".to_string()); + } + + let dkim_set = self.email.dkim.selector.is_some() + || self.email.dkim.domain.is_some() + || self.email.dkim.private_key_path.is_some(); + if dkim_set { + if self.email.dkim.selector.is_none() { + errors + .push("email.dkim.selector is required when any DKIM field is set".to_string()); + } + if self.email.dkim.domain.is_none() { + errors.push("email.dkim.domain is required when any DKIM field is set".to_string()); + } + if self.email.dkim.private_key_path.is_none() { + errors.push( + "email.dkim.private_key_path is required when any DKIM field is set" + .to_string(), + ); + } + } + // -- telegram --------------------------------------------------------- if self.telegram.bot_token.is_some() && self.telegram.webhook_secret.is_none() { errors.push( @@ -754,9 +849,98 @@ pub struct EmailConfig { #[config(env = "MAIL_FROM_NAME", default = "Tranquil PDS")] pub from_name: String, - /// Path to the `sendmail` binary. - #[config(env = "SENDMAIL_PATH", default = "/usr/sbin/sendmail")] - pub sendmail_path: String, + /// HELO/EHLO name announced to remote SMTP servers. Applies to both + /// smarthost and direct-MX modes. Defaults to the server hostname. + #[config(env = "MAIL_HELO_NAME")] + pub helo_name: Option, + + #[config(nested)] + pub smarthost: SmarthostConfig, + + #[config(nested)] + pub direct_mx: DirectMxConfig, + + #[config(nested)] + pub dkim: DkimConfig, +} + +#[derive(Debug, Config)] +pub struct SmarthostConfig { + /// SMTP relay host. When set, mail is delivered through this host + /// instead of resolving recipient MX records directly. + #[config(env = "MAIL_SMARTHOST_HOST")] + pub host: Option, + + /// SMTP relay port. + #[config(env = "MAIL_SMARTHOST_PORT", default = 587)] + pub port: u16, + + /// SMTP authentication username. + #[config(env = "MAIL_SMARTHOST_USERNAME")] + pub username: Option, + + /// SMTP authentication password. + #[config(env = "MAIL_SMARTHOST_PASSWORD")] + pub password: Option, + + /// TLS mode. Valid values: "implicit", "starttls", "none". Setting "none" + /// alongside a password is rejected at startup to prevent transmitting + /// credentials in plaintext. + #[config(env = "MAIL_SMARTHOST_TLS", default = "starttls")] + pub tls: String, + + /// Max size of the connection pool. + #[config(env = "MAIL_SMARTHOST_POOL_SIZE", default = 4)] + pub pool_size: u32, + + /// Per-command SMTP timeout in seconds. Bounds the security handshake. + #[config(env = "MAIL_SMARTHOST_COMMAND_TIMEOUT_SECS", default = 30)] + pub command_timeout_secs: u64, + + /// Total per-message timeout in seconds. Wraps the entire send so a + /// stuck relay cannot stall the comms queue. + #[config(env = "MAIL_SMARTHOST_TOTAL_TIMEOUT_SECS", default = 60)] + pub total_timeout_secs: u64, +} + +#[derive(Debug, Config)] +pub struct DirectMxConfig { + /// Per-command SMTP timeout in seconds. + #[config(env = "MAIL_COMMAND_TIMEOUT_SECS", default = 30)] + pub command_timeout_secs: u64, + + /// Total per-message timeout across all MX attempts in seconds. + #[config(env = "MAIL_TOTAL_TIMEOUT_SECS", default = 60)] + pub total_timeout_secs: u64, + + /// Max number of concurrent direct-MX sends. Limits the load placed + /// on any single recipient MX during a backlog drain. + #[config(env = "MAIL_MAX_CONCURRENT_SENDS", default = 8)] + pub max_concurrent_sends: usize, + + /// Require STARTTLS on every MX hop. When false, TLS is + /// attempted opportunistically and the session falls back to plaintext + /// if the remote does not advertise STARTTLS. Set true to refuse + /// plaintext delivery, at the cost of failing sends to MX hosts that + /// do not support TLS. + #[config(env = "MAIL_REQUIRE_TLS", default = false)] + pub require_tls: bool, +} + +#[derive(Debug, Config)] +pub struct DkimConfig { + /// DKIM selector. When unset, outgoing mail is not signed. + #[config(env = "MAIL_DKIM_SELECTOR")] + pub selector: Option, + + /// DKIM signing domain. + #[config(env = "MAIL_DKIM_DOMAIN")] + pub domain: Option, + + /// Path to the DKIM private key in PEM format. Supports RSA and + /// Ed25519 keys. + #[config(env = "MAIL_DKIM_KEY_PATH")] + pub private_key_path: Option, } #[derive(Debug, Config)] @@ -1196,3 +1380,64 @@ pub struct TranquilStoreConfig { pub fn template() -> String { confique::toml::template::(confique::toml::FormatOptions::default()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn seed_required_env() { + let required = [ + ("PDS_HOSTNAME", "test.local"), + ("DATABASE_URL", "postgres://localhost/test"), + ("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", "1"), + ("INVITE_CODE_REQUIRED", "false"), + ("ENABLE_PDS_HOSTED_DID_WEB", "true"), + ("TRANQUIL_LEXICON_OFFLINE", "1"), + ]; + required + .iter() + .filter(|(k, _)| std::env::var_os(k).is_none()) + .for_each(|(k, v)| unsafe { std::env::set_var(k, v) }); + } + + #[test] + fn serial_validate_rejects_legacy_sendmail_path() { + seed_required_env(); + unsafe { std::env::set_var("SENDMAIL_PATH", "/usr/sbin/sendmail") }; + let config = TranquilConfig::builder() + .env() + .load() + .expect("load fresh config"); + let result = config.validate(true); + unsafe { std::env::remove_var("SENDMAIL_PATH") }; + + let err = result.expect_err("validate must reject SENDMAIL_PATH"); + let mentions_sendmail = err.errors.iter().any(|e| e.contains("SENDMAIL_PATH")); + assert!( + mentions_sendmail, + "errors did not mention SENDMAIL_PATH: {:?}", + err.errors + ); + } + + #[test] + fn serial_validate_passes_when_no_legacy_env_set() { + seed_required_env(); + unsafe { std::env::remove_var("SENDMAIL_PATH") }; + let config = TranquilConfig::builder() + .env() + .load() + .expect("load fresh config"); + let result = config.validate(true); + let leaked_legacy = result + .as_ref() + .err() + .map(|e| e.errors.iter().any(|s| s.contains("SENDMAIL_PATH"))) + .unwrap_or(false); + assert!( + !leaked_legacy, + "validate spuriously flagged SENDMAIL_PATH when unset: {:?}", + result + ); + } +} diff --git a/example.toml b/example.toml index 49e9138..1cc1012 100644 --- a/example.toml +++ b/example.toml @@ -373,12 +373,117 @@ # Default value: "Tranquil PDS" #from_name = "Tranquil PDS" -# Path to the `sendmail` binary. +# HELO/EHLO name announced to remote SMTP servers. Applies to both +# smarthost and direct-MX modes. Defaults to the server hostname. # -# Can also be specified via environment variable `SENDMAIL_PATH`. +# Can also be specified via environment variable `MAIL_HELO_NAME`. +#helo_name = + +[email.smarthost] +# SMTP relay host. When set, mail is delivered through this host +# instead of resolving recipient MX records directly. # -# Default value: "/usr/sbin/sendmail" -#sendmail_path = "/usr/sbin/sendmail" +# Can also be specified via environment variable `MAIL_SMARTHOST_HOST`. +#host = + +# SMTP relay port. +# +# Can also be specified via environment variable `MAIL_SMARTHOST_PORT`. +# +# Default value: 587 +#port = 587 + +# SMTP authentication username. +# +# Can also be specified via environment variable `MAIL_SMARTHOST_USERNAME`. +#username = + +# SMTP authentication password. +# +# Can also be specified via environment variable `MAIL_SMARTHOST_PASSWORD`. +#password = + +# TLS mode. Valid values: "implicit", "starttls", "none". Setting "none" +# alongside a password is rejected at startup to prevent transmitting +# credentials in plaintext. +# +# Can also be specified via environment variable `MAIL_SMARTHOST_TLS`. +# +# Default value: "starttls" +#tls = "starttls" + +# Max size of the connection pool. +# +# Can also be specified via environment variable `MAIL_SMARTHOST_POOL_SIZE`. +# +# Default value: 4 +#pool_size = 4 + +# Per-command SMTP timeout in seconds. Bounds the security handshake. +# +# Can also be specified via environment variable `MAIL_SMARTHOST_COMMAND_TIMEOUT_SECS`. +# +# Default value: 30 +#command_timeout_secs = 30 + +# Total per-message timeout in seconds. Wraps the entire send so a +# stuck relay cannot stall the comms queue. +# +# Can also be specified via environment variable `MAIL_SMARTHOST_TOTAL_TIMEOUT_SECS`. +# +# Default value: 60 +#total_timeout_secs = 60 + +[email.direct_mx] +# Per-command SMTP timeout in seconds. +# +# Can also be specified via environment variable `MAIL_COMMAND_TIMEOUT_SECS`. +# +# Default value: 30 +#command_timeout_secs = 30 + +# Total per-message timeout across all MX attempts in seconds. +# +# Can also be specified via environment variable `MAIL_TOTAL_TIMEOUT_SECS`. +# +# Default value: 60 +#total_timeout_secs = 60 + +# Max number of concurrent direct-MX sends. Limits the load placed +# on any single recipient MX during a backlog drain. +# +# Can also be specified via environment variable `MAIL_MAX_CONCURRENT_SENDS`. +# +# Default value: 8 +#max_concurrent_sends = 8 + +# Require STARTTLS on every MX hop. When false, TLS is +# attempted opportunistically and the session falls back to plaintext +# if the remote does not advertise STARTTLS. Set true to refuse +# plaintext delivery, at the cost of failing sends to MX hosts that +# do not support TLS. +# +# Can also be specified via environment variable `MAIL_REQUIRE_TLS`. +# +# Default value: false +#require_tls = false + +[email.dkim] +# DKIM selector. When unset, outgoing mail is not signed. +# +# Can also be specified via environment variable `MAIL_DKIM_SELECTOR`. +#selector = + +# DKIM signing domain. +# +# Can also be specified via environment variable `MAIL_DKIM_DOMAIN`. +#domain = + +# Path to the DKIM private key in PEM format. Supports RSA and +# Ed25519 keys. +# +# Can also be specified via environment variable `MAIL_DKIM_KEY_PATH`. +#private_key_path = [discord] # Discord bot token. When unset, Discord integration is disabled.