Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(tranquil-comms): prework for email

Lewis: May this revision serve well! <lu5a@proton.me>

+703 -38
+110 -30
Cargo.lock
··· 9 9 checksum = "087113bd50d9adce24850eed5d0476c7d199d532fce8fab5173650331e09033a" 10 10 dependencies = [ 11 11 "abnf-core", 12 - "nom", 12 + "nom 7.1.3", 13 13 ] 14 14 15 15 [[package]] ··· 18 18 source = "registry+https://github.com/rust-lang/crates.io-index" 19 19 checksum = "c44e09c43ae1c368fb91a03a566472d0087c26cf7e1b9e8e289c14ede681dd7d" 20 20 dependencies = [ 21 - "nom", 21 + "nom 7.1.3", 22 22 ] 23 23 24 24 [[package]] ··· 213 213 "asn1-rs-derive", 214 214 "asn1-rs-impl", 215 215 "displaydoc", 216 - "nom", 216 + "nom 7.1.3", 217 217 "num-traits", 218 218 "rusticata-macros", 219 219 "thiserror 1.0.69", ··· 1972 1972 dependencies = [ 1973 1973 "asn1-rs", 1974 1974 "displaydoc", 1975 - "nom", 1975 + "nom 7.1.3", 1976 1976 "num-bigint", 1977 1977 "num-traits", 1978 1978 "rusticata-macros", ··· 2217 2217 ] 2218 2218 2219 2219 [[package]] 2220 + name = "email-encoding" 2221 + version = "0.4.1" 2222 + source = "registry+https://github.com/rust-lang/crates.io-index" 2223 + checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" 2224 + dependencies = [ 2225 + "base64 0.22.1", 2226 + "memchr", 2227 + ] 2228 + 2229 + [[package]] 2230 + name = "email_address" 2231 + version = "0.2.9" 2232 + source = "registry+https://github.com/rust-lang/crates.io-index" 2233 + checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" 2234 + 2235 + [[package]] 2220 2236 name = "embedded-io" 2221 2237 version = "0.4.0" 2222 2238 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3781 3797 checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" 3782 3798 3783 3799 [[package]] 3800 + name = "lettre" 3801 + version = "0.11.21" 3802 + source = "registry+https://github.com/rust-lang/crates.io-index" 3803 + checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7" 3804 + dependencies = [ 3805 + "async-trait", 3806 + "base64 0.22.1", 3807 + "ed25519-dalek", 3808 + "email-encoding", 3809 + "email_address", 3810 + "fastrand", 3811 + "futures-io", 3812 + "futures-util", 3813 + "httpdate", 3814 + "idna", 3815 + "mime", 3816 + "nom 8.0.0", 3817 + "percent-encoding", 3818 + "quoted_printable", 3819 + "rsa", 3820 + "rustls 0.23.37", 3821 + "sha2", 3822 + "socket2 0.6.3", 3823 + "tokio", 3824 + "tokio-rustls 0.26.4", 3825 + "tracing", 3826 + "url", 3827 + "webpki-roots 1.0.6", 3828 + ] 3829 + 3830 + [[package]] 3784 3831 name = "libc" 3785 3832 version = "0.2.183" 3786 3833 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4410 4457 ] 4411 4458 4412 4459 [[package]] 4460 + name = "nom" 4461 + version = "8.0.0" 4462 + source = "registry+https://github.com/rust-lang/crates.io-index" 4463 + checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" 4464 + dependencies = [ 4465 + "memchr", 4466 + ] 4467 + 4468 + [[package]] 4413 4469 name = "nonzero_ext" 4414 4470 version = "0.3.0" 4415 4471 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4839 4895 dependencies = [ 4840 4896 "either", 4841 4897 "fnv", 4842 - "nom", 4898 + "nom 7.1.3", 4843 4899 "once_cell", 4844 4900 "postcard", 4845 4901 "quick-xml", ··· 5428 5484 ] 5429 5485 5430 5486 [[package]] 5487 + name = "quoted_printable" 5488 + version = "0.5.2" 5489 + source = "registry+https://github.com/rust-lang/crates.io-index" 5490 + checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" 5491 + 5492 + [[package]] 5431 5493 name = "r-efi" 5432 5494 version = "5.3.0" 5433 5495 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5833 5895 source = "registry+https://github.com/rust-lang/crates.io-index" 5834 5896 checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" 5835 5897 dependencies = [ 5836 - "nom", 5898 + "nom 7.1.3", 5837 5899 ] 5838 5900 5839 5901 [[package]] ··· 6047 6109 "generic-array", 6048 6110 "pkcs8 0.10.2", 6049 6111 "subtle", 6112 + "zeroize", 6113 + ] 6114 + 6115 + [[package]] 6116 + name = "secrecy" 6117 + version = "0.10.3" 6118 + source = "registry+https://github.com/rust-lang/crates.io-index" 6119 + checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" 6120 + dependencies = [ 6121 + "serde", 6050 6122 "zeroize", 6051 6123 ] 6052 6124 ··· 7455 7527 7456 7528 [[package]] 7457 7529 name = "tranquil-api" 7458 - version = "0.5.7" 7530 + version = "0.5.8" 7459 7531 dependencies = [ 7460 7532 "anyhow", 7461 7533 "axum", ··· 7506 7578 7507 7579 [[package]] 7508 7580 name = "tranquil-auth" 7509 - version = "0.5.7" 7581 + version = "0.5.8" 7510 7582 dependencies = [ 7511 7583 "anyhow", 7512 7584 "base32", ··· 7529 7601 7530 7602 [[package]] 7531 7603 name = "tranquil-cache" 7532 - version = "0.5.7" 7604 + version = "0.5.8" 7533 7605 dependencies = [ 7534 7606 "async-trait", 7535 7607 "base64 0.22.1", ··· 7543 7615 7544 7616 [[package]] 7545 7617 name = "tranquil-comms" 7546 - version = "0.5.7" 7618 + version = "0.5.8" 7547 7619 dependencies = [ 7548 7620 "async-trait", 7549 7621 "base64 0.22.1", 7622 + "chrono", 7623 + "ed25519-dalek", 7624 + "futures", 7625 + "hickory-resolver", 7626 + "lettre", 7627 + "rand 0.8.5", 7550 7628 "reqwest", 7629 + "rsa", 7630 + "secrecy", 7551 7631 "serde_json", 7552 7632 "sqlx", 7553 7633 "thiserror 2.0.18", ··· 7561 7641 7562 7642 [[package]] 7563 7643 name = "tranquil-config" 7564 - version = "0.5.7" 7644 + version = "0.5.8" 7565 7645 dependencies = [ 7566 7646 "confique", 7567 7647 "serde", ··· 7569 7649 7570 7650 [[package]] 7571 7651 name = "tranquil-crypto" 7572 - version = "0.5.7" 7652 + version = "0.5.8" 7573 7653 dependencies = [ 7574 7654 "aes-gcm", 7575 7655 "base64 0.22.1", ··· 7585 7665 7586 7666 [[package]] 7587 7667 name = "tranquil-db" 7588 - version = "0.5.7" 7668 + version = "0.5.8" 7589 7669 dependencies = [ 7590 7670 "async-trait", 7591 7671 "chrono", ··· 7602 7682 7603 7683 [[package]] 7604 7684 name = "tranquil-db-traits" 7605 - version = "0.5.7" 7685 + version = "0.5.8" 7606 7686 dependencies = [ 7607 7687 "async-trait", 7608 7688 "base64 0.22.1", ··· 7618 7698 7619 7699 [[package]] 7620 7700 name = "tranquil-infra" 7621 - version = "0.5.7" 7701 + version = "0.5.8" 7622 7702 dependencies = [ 7623 7703 "async-trait", 7624 7704 "bytes", ··· 7629 7709 7630 7710 [[package]] 7631 7711 name = "tranquil-lexicon" 7632 - version = "0.5.7" 7712 + version = "0.5.8" 7633 7713 dependencies = [ 7634 7714 "chrono", 7635 7715 "futures", ··· 7648 7728 7649 7729 [[package]] 7650 7730 name = "tranquil-oauth" 7651 - version = "0.5.7" 7731 + version = "0.5.8" 7652 7732 dependencies = [ 7653 7733 "anyhow", 7654 7734 "axum", ··· 7671 7751 7672 7752 [[package]] 7673 7753 name = "tranquil-oauth-server" 7674 - version = "0.5.7" 7754 + version = "0.5.8" 7675 7755 dependencies = [ 7676 7756 "axum", 7677 7757 "base64 0.22.1", ··· 7704 7784 7705 7785 [[package]] 7706 7786 name = "tranquil-pds" 7707 - version = "0.5.7" 7787 + version = "0.5.8" 7708 7788 dependencies = [ 7709 7789 "aes-gcm", 7710 7790 "anyhow", ··· 7796 7876 7797 7877 [[package]] 7798 7878 name = "tranquil-repo" 7799 - version = "0.5.7" 7879 + version = "0.5.8" 7800 7880 dependencies = [ 7801 7881 "bytes", 7802 7882 "cid", ··· 7808 7888 7809 7889 [[package]] 7810 7890 name = "tranquil-ripple" 7811 - version = "0.5.7" 7891 + version = "0.5.8" 7812 7892 dependencies = [ 7813 7893 "async-trait", 7814 7894 "backon", ··· 7833 7913 7834 7914 [[package]] 7835 7915 name = "tranquil-scopes" 7836 - version = "0.5.7" 7916 + version = "0.5.8" 7837 7917 dependencies = [ 7838 7918 "axum", 7839 7919 "futures", ··· 7849 7929 7850 7930 [[package]] 7851 7931 name = "tranquil-server" 7852 - version = "0.5.7" 7932 + version = "0.5.8" 7853 7933 dependencies = [ 7854 7934 "axum", 7855 7935 "clap", ··· 7870 7950 7871 7951 [[package]] 7872 7952 name = "tranquil-signal" 7873 - version = "0.5.7" 7953 + version = "0.5.8" 7874 7954 dependencies = [ 7875 7955 "async-trait", 7876 7956 "chrono", ··· 7893 7973 7894 7974 [[package]] 7895 7975 name = "tranquil-storage" 7896 - version = "0.5.7" 7976 + version = "0.5.8" 7897 7977 dependencies = [ 7898 7978 "async-trait", 7899 7979 "aws-config", ··· 7910 7990 7911 7991 [[package]] 7912 7992 name = "tranquil-store" 7913 - version = "0.5.7" 7993 + version = "0.5.8" 7914 7994 dependencies = [ 7915 7995 "async-trait", 7916 7996 "bytes", ··· 7959 8039 7960 8040 [[package]] 7961 8041 name = "tranquil-sync" 7962 - version = "0.5.7" 8042 + version = "0.5.8" 7963 8043 dependencies = [ 7964 8044 "anyhow", 7965 8045 "axum", ··· 7981 8061 7982 8062 [[package]] 7983 8063 name = "tranquil-types" 7984 - version = "0.5.7" 8064 + version = "0.5.8" 7985 8065 dependencies = [ 7986 8066 "chrono", 7987 8067 "cid", ··· 8496 8576 "base64urlsafedata", 8497 8577 "der-parser", 8498 8578 "hex", 8499 - "nom", 8579 + "nom 7.1.3", 8500 8580 "openssl", 8501 8581 "openssl-sys", 8502 8582 "rand 0.9.2", ··· 9068 9148 "data-encoding", 9069 9149 "der-parser", 9070 9150 "lazy_static", 9071 - "nom", 9151 + "nom 7.1.3", 9072 9152 "oid-registry", 9073 9153 "rusticata-macros", 9074 9154 "thiserror 1.0.69",
+4 -1
Cargo.toml
··· 26 26 ] 27 27 28 28 [workspace.package] 29 - version = "0.5.7" 29 + version = "0.5.8" 30 30 edition = "2024" 31 31 license = "AGPL-3.0-or-later" 32 32 ··· 93 93 iroh-car = "0.5" 94 94 jacquard-common = { version = "0.9", features = ["crypto-k256"] } 95 95 jacquard-repo = "0.9" 96 + lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls-tls", "pool", "dkim", "tracing"] } 96 97 jsonwebtoken = { version = "10.2", features = ["rust_crypto"] } 97 98 k256 = { version = "0.13", features = ["ecdsa", "pem", "pkcs8"] } 98 99 metrics = "0.24" ··· 105 106 rand = "0.8" 106 107 redis = { version = "1.0", features = ["tokio-comp", "connection-manager"] } 107 108 regex = "1" 109 + rsa = "0.9" 110 + secrecy = { version = "0.10", features = ["serde"] } 108 111 reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "http2", "charset", "macos-system-configuration"] } 109 112 serde = { version = "1.0", features = ["derive"] } 110 113 serde_bytes = "0.11"
+11
crates/tranquil-comms/Cargo.toml
··· 10 10 11 11 async-trait = { workspace = true } 12 12 base64 = { workspace = true } 13 + ed25519-dalek = { workspace = true } 14 + futures = { workspace = true } 15 + hickory-resolver = { workspace = true } 16 + lettre = { workspace = true } 17 + rand = { workspace = true } 13 18 reqwest = { workspace = true } 19 + rsa = { workspace = true } 20 + secrecy = { workspace = true } 14 21 serde_json = { workspace = true } 15 22 sqlx = { workspace = true } 16 23 thiserror = { workspace = true } ··· 18 25 tracing = { workspace = true } 19 26 tranquil-db-traits = { workspace = true } 20 27 uuid = { workspace = true } 28 + 29 + [dev-dependencies] 30 + chrono = { workspace = true } 31 + tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time", "io-util", "net"] }
+298
crates/tranquil-comms/src/email/types.rs
··· 1 + use std::path::PathBuf; 2 + 3 + #[derive(Debug, thiserror::Error)] 4 + pub enum ParseError { 5 + #[error("empty value")] 6 + Empty, 7 + #[error("invalid character {0:?}")] 8 + InvalidChar(char), 9 + #[error("zero {0}")] 10 + Zero(&'static str), 11 + #[error("invalid TLS mode {0:?}")] 12 + InvalidTlsMode(String), 13 + } 14 + 15 + fn parse_token( 16 + raw: &str, 17 + lowercase: bool, 18 + strip_trailing_dot: bool, 19 + ) -> Result<String, ParseError> { 20 + let mut s = raw.trim(); 21 + if strip_trailing_dot { 22 + s = s.trim_end_matches('.'); 23 + } 24 + match s { 25 + "" => Err(ParseError::Empty), 26 + _ if s.chars().any(char::is_whitespace) => Err(ParseError::InvalidChar(' ')), 27 + _ => Ok(match lowercase { 28 + true => s.to_lowercase(), 29 + false => s.to_string(), 30 + }), 31 + } 32 + } 33 + 34 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 35 + pub struct SmtpHost(String); 36 + 37 + impl SmtpHost { 38 + pub fn parse(raw: &str) -> Result<Self, ParseError> { 39 + parse_token(raw, true, false).map(Self) 40 + } 41 + 42 + pub fn as_str(&self) -> &str { 43 + &self.0 44 + } 45 + } 46 + 47 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 48 + pub struct SmtpPort(u16); 49 + 50 + impl SmtpPort { 51 + pub fn parse(raw: u16) -> Result<Self, ParseError> { 52 + match raw { 53 + 0 => Err(ParseError::Zero("smtp port")), 54 + n => Ok(Self(n)), 55 + } 56 + } 57 + 58 + pub fn as_u16(self) -> u16 { 59 + self.0 60 + } 61 + } 62 + 63 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 64 + pub struct HeloName(String); 65 + 66 + impl HeloName { 67 + pub fn parse(raw: &str) -> Result<Self, ParseError> { 68 + parse_token(raw, false, false).map(Self) 69 + } 70 + 71 + pub fn as_str(&self) -> &str { 72 + &self.0 73 + } 74 + 75 + pub fn into_inner(self) -> String { 76 + self.0 77 + } 78 + } 79 + 80 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 81 + pub struct EmailDomain(String); 82 + 83 + impl EmailDomain { 84 + pub fn parse(raw: &str) -> Result<Self, ParseError> { 85 + parse_token(raw, true, true).map(Self) 86 + } 87 + 88 + pub fn as_str(&self) -> &str { 89 + &self.0 90 + } 91 + 92 + pub fn into_inner(self) -> String { 93 + self.0 94 + } 95 + } 96 + 97 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 98 + pub struct MxHost(String); 99 + 100 + impl MxHost { 101 + pub fn parse(raw: &str) -> Result<Self, ParseError> { 102 + parse_token(raw, true, true).map(Self) 103 + } 104 + 105 + pub fn as_str(&self) -> &str { 106 + &self.0 107 + } 108 + } 109 + 110 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] 111 + pub struct MxPriority(u16); 112 + 113 + impl MxPriority { 114 + pub fn new(value: u16) -> Self { 115 + Self(value) 116 + } 117 + 118 + pub fn as_u16(self) -> u16 { 119 + self.0 120 + } 121 + } 122 + 123 + #[derive(Debug, Clone)] 124 + pub struct MxRecord { 125 + pub priority: MxPriority, 126 + pub host: MxHost, 127 + } 128 + 129 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 130 + pub struct DkimSelector(String); 131 + 132 + impl DkimSelector { 133 + pub fn parse(raw: &str) -> Result<Self, ParseError> { 134 + let trimmed = raw.trim(); 135 + let valid = !trimmed.is_empty() && trimmed.split('.').all(valid_subdomain); 136 + match valid { 137 + true => Ok(Self(trimmed.to_string())), 138 + false => Err(ParseError::InvalidChar('?')), 139 + } 140 + } 141 + 142 + pub fn into_inner(self) -> String { 143 + self.0 144 + } 145 + } 146 + 147 + fn valid_subdomain(seg: &str) -> bool { 148 + let starts_alnum = seg 149 + .chars() 150 + .next() 151 + .is_some_and(|c| c.is_ascii_alphanumeric()); 152 + let ends_alnum = seg 153 + .chars() 154 + .next_back() 155 + .is_some_and(|c| c.is_ascii_alphanumeric()); 156 + let body_ok = seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'); 157 + starts_alnum && ends_alnum && body_ok 158 + } 159 + 160 + #[derive(Debug, Clone)] 161 + pub struct DkimKeyPath(PathBuf); 162 + 163 + impl DkimKeyPath { 164 + pub fn parse(raw: &str) -> Result<Self, ParseError> { 165 + let trimmed = raw.trim(); 166 + match trimmed.is_empty() { 167 + true => Err(ParseError::Empty), 168 + false => Ok(Self(PathBuf::from(trimmed))), 169 + } 170 + } 171 + 172 + pub fn as_path(&self) -> &std::path::Path { 173 + &self.0 174 + } 175 + } 176 + 177 + #[derive(Debug, Clone, PartialEq, Eq)] 178 + pub struct SmtpUsername(String); 179 + 180 + impl SmtpUsername { 181 + pub fn parse(raw: &str) -> Result<Self, ParseError> { 182 + match raw.is_empty() { 183 + true => Err(ParseError::Empty), 184 + false => Ok(Self(raw.to_string())), 185 + } 186 + } 187 + 188 + pub fn into_inner(self) -> String { 189 + self.0 190 + } 191 + } 192 + 193 + #[derive(Clone)] 194 + pub struct SmtpPassword(secrecy::SecretString); 195 + 196 + impl SmtpPassword { 197 + pub fn parse(raw: &str) -> Result<Self, ParseError> { 198 + match raw.is_empty() { 199 + true => Err(ParseError::Empty), 200 + false => Ok(Self(secrecy::SecretString::from(raw.to_string()))), 201 + } 202 + } 203 + 204 + pub fn expose(&self) -> &str { 205 + use secrecy::ExposeSecret; 206 + self.0.expose_secret() 207 + } 208 + } 209 + 210 + impl std::fmt::Debug for SmtpPassword { 211 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 212 + f.write_str("SmtpPassword(***)") 213 + } 214 + } 215 + 216 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 217 + pub enum TlsMode { 218 + Implicit, 219 + Starttls, 220 + None, 221 + } 222 + 223 + impl TlsMode { 224 + pub fn parse(raw: &str) -> Result<Self, ParseError> { 225 + match raw.to_ascii_lowercase().as_str() { 226 + "implicit" => Ok(Self::Implicit), 227 + "starttls" => Ok(Self::Starttls), 228 + "none" => Ok(Self::None), 229 + other => Err(ParseError::InvalidTlsMode(other.to_string())), 230 + } 231 + } 232 + } 233 + 234 + #[cfg(test)] 235 + mod tests { 236 + use super::*; 237 + 238 + #[test] 239 + fn smtp_host_lowercases_and_trims() { 240 + let h = SmtpHost::parse(" SMTP.NEL.PET ").unwrap(); 241 + assert_eq!(h.as_str(), "smtp.nel.pet"); 242 + } 243 + 244 + #[test] 245 + fn smtp_host_rejects_whitespace() { 246 + assert!(SmtpHost::parse("a b").is_err()); 247 + } 248 + 249 + #[test] 250 + fn smtp_host_rejects_empty() { 251 + assert!(SmtpHost::parse("").is_err()); 252 + assert!(SmtpHost::parse(" ").is_err()); 253 + } 254 + 255 + #[test] 256 + fn smtp_port_rejects_zero() { 257 + assert!(SmtpPort::parse(0).is_err()); 258 + assert_eq!(SmtpPort::parse(587).unwrap().as_u16(), 587); 259 + } 260 + 261 + #[test] 262 + fn email_domain_strips_trailing_dot() { 263 + assert_eq!( 264 + EmailDomain::parse("Nel.pet.").unwrap().as_str(), 265 + "nel.pet" 266 + ); 267 + } 268 + 269 + #[test] 270 + fn dkim_selector_validates() { 271 + assert!(DkimSelector::parse("default").is_ok()); 272 + assert!(DkimSelector::parse("s1.nel.pet").is_ok()); 273 + assert!(DkimSelector::parse("s2024-q1").is_ok()); 274 + assert!(DkimSelector::parse("mailo-2024.nel.pet").is_ok()); 275 + assert!(DkimSelector::parse("a-b").is_ok()); 276 + assert!(DkimSelector::parse("").is_err()); 277 + assert!(DkimSelector::parse("a..b").is_err()); 278 + assert!(DkimSelector::parse("-leading").is_err()); 279 + assert!(DkimSelector::parse("trailing-").is_err()); 280 + assert!(DkimSelector::parse("s_under").is_err()); 281 + } 282 + 283 + #[test] 284 + fn tls_mode_parses_known_modes() { 285 + assert_eq!(TlsMode::parse("STARTTLS").unwrap(), TlsMode::Starttls); 286 + assert_eq!(TlsMode::parse("implicit").unwrap(), TlsMode::Implicit); 287 + assert_eq!(TlsMode::parse("none").unwrap(), TlsMode::None); 288 + assert!(TlsMode::parse("garbage").is_err()); 289 + } 290 + 291 + #[test] 292 + fn smtp_password_redacts_in_debug() { 293 + let p = SmtpPassword::parse("hunter2").unwrap(); 294 + let dbg = format!("{:?}", p); 295 + assert_eq!(dbg, "SmtpPassword(***)"); 296 + assert!(!dbg.contains("hunter2")); 297 + } 298 + }
+171 -3
crates/tranquil-config/src/lib.rs
··· 210 210 } 211 211 } 212 212 213 + // -- email smarthost -------------------------------------------------- 214 + match self.email.smarthost.tls.to_ascii_lowercase().as_str() { 215 + "implicit" | "starttls" => {} 216 + "none" => { 217 + if self.email.smarthost.password.is_some() { 218 + errors.push( 219 + "email.smarthost.tls = \"none\" with email.smarthost.password set \ 220 + would transmit credentials in plaintext; use \"starttls\" or \"implicit\"" 221 + .to_string(), 222 + ); 223 + } 224 + } 225 + other => errors.push(format!( 226 + "email.smarthost.tls must be \"implicit\", \"starttls\", or \"none\", got \"{other}\"" 227 + )), 228 + } 229 + 230 + let smarthost_host_set = self 231 + .email 232 + .smarthost 233 + .host 234 + .as_deref() 235 + .is_some_and(|h| !h.is_empty()); 236 + let username_set = self.email.smarthost.username.is_some(); 237 + let password_set = self.email.smarthost.password.is_some(); 238 + if !smarthost_host_set && (username_set || password_set) { 239 + errors.push( 240 + "email.smarthost.username or email.smarthost.password is set but \ 241 + email.smarthost.host is empty; credentials would be silently ignored" 242 + .to_string(), 243 + ); 244 + } 245 + if smarthost_host_set && username_set != password_set { 246 + errors.push( 247 + "email.smarthost.username and email.smarthost.password must both be set or \ 248 + both unset; otherwise authentication would silently degrade to anonymous" 249 + .to_string(), 250 + ); 251 + } 252 + 253 + if self.email.smarthost.command_timeout_secs == 0 { 254 + errors.push("email.smarthost.command_timeout_secs must be at least 1".to_string()); 255 + } 256 + if self.email.smarthost.total_timeout_secs == 0 { 257 + errors.push("email.smarthost.total_timeout_secs must be at least 1".to_string()); 258 + } 259 + if self.email.smarthost.pool_size == 0 { 260 + errors.push("email.smarthost.pool_size must be at least 1".to_string()); 261 + } 262 + 263 + if self.email.direct_mx.max_concurrent_sends == 0 { 264 + errors.push("email.direct_mx.max_concurrent_sends must be at least 1".to_string()); 265 + } 266 + if self.email.direct_mx.command_timeout_secs == 0 { 267 + errors.push("email.direct_mx.command_timeout_secs must be at least 1".to_string()); 268 + } 269 + if self.email.direct_mx.total_timeout_secs == 0 { 270 + errors.push("email.direct_mx.total_timeout_secs must be at least 1".to_string()); 271 + } 272 + 273 + let dkim_set = self.email.dkim.selector.is_some() 274 + || self.email.dkim.domain.is_some() 275 + || self.email.dkim.private_key_path.is_some(); 276 + if dkim_set { 277 + if self.email.dkim.selector.is_none() { 278 + errors 279 + .push("email.dkim.selector is required when any DKIM field is set".to_string()); 280 + } 281 + if self.email.dkim.domain.is_none() { 282 + errors.push("email.dkim.domain is required when any DKIM field is set".to_string()); 283 + } 284 + if self.email.dkim.private_key_path.is_none() { 285 + errors.push( 286 + "email.dkim.private_key_path is required when any DKIM field is set" 287 + .to_string(), 288 + ); 289 + } 290 + } 291 + 213 292 // -- telegram --------------------------------------------------------- 214 293 if self.telegram.bot_token.is_some() && self.telegram.webhook_secret.is_none() { 215 294 errors.push( ··· 754 833 #[config(env = "MAIL_FROM_NAME", default = "Tranquil PDS")] 755 834 pub from_name: String, 756 835 757 - /// Path to the `sendmail` binary. 758 - #[config(env = "SENDMAIL_PATH", default = "/usr/sbin/sendmail")] 759 - pub sendmail_path: String, 836 + /// HELO/EHLO name announced to remote SMTP servers. Applies to both 837 + /// smarthost and direct-MX modes. Defaults to the server hostname. 838 + #[config(env = "MAIL_HELO_NAME")] 839 + pub helo_name: Option<String>, 840 + 841 + #[config(nested)] 842 + pub smarthost: SmarthostConfig, 843 + 844 + #[config(nested)] 845 + pub direct_mx: DirectMxConfig, 846 + 847 + #[config(nested)] 848 + pub dkim: DkimConfig, 849 + } 850 + 851 + #[derive(Debug, Config)] 852 + pub struct SmarthostConfig { 853 + /// SMTP relay host. When set, mail is delivered through this host 854 + /// instead of resolving recipient MX records directly. 855 + #[config(env = "MAIL_SMARTHOST_HOST")] 856 + pub host: Option<String>, 857 + 858 + /// SMTP relay port. 859 + #[config(env = "MAIL_SMARTHOST_PORT", default = 587)] 860 + pub port: u16, 861 + 862 + /// SMTP authentication username. 863 + #[config(env = "MAIL_SMARTHOST_USERNAME")] 864 + pub username: Option<String>, 865 + 866 + /// SMTP authentication password. 867 + #[config(env = "MAIL_SMARTHOST_PASSWORD")] 868 + pub password: Option<String>, 869 + 870 + /// TLS mode. Valid values: "implicit", "starttls", "none". Setting "none" 871 + /// alongside a password is rejected at startup to prevent transmitting 872 + /// credentials in plaintext. 873 + #[config(env = "MAIL_SMARTHOST_TLS", default = "starttls")] 874 + pub tls: String, 875 + 876 + /// Max size of the connection pool. 877 + #[config(env = "MAIL_SMARTHOST_POOL_SIZE", default = 4)] 878 + pub pool_size: u32, 879 + 880 + /// Per-command SMTP timeout in seconds. Bounds the security handshake. 881 + #[config(env = "MAIL_SMARTHOST_COMMAND_TIMEOUT_SECS", default = 30)] 882 + pub command_timeout_secs: u64, 883 + 884 + /// Total per-message timeout in seconds. Wraps the entire send so a 885 + /// stuck relay cannot stall the comms queue. 886 + #[config(env = "MAIL_SMARTHOST_TOTAL_TIMEOUT_SECS", default = 60)] 887 + pub total_timeout_secs: u64, 888 + } 889 + 890 + #[derive(Debug, Config)] 891 + pub struct DirectMxConfig { 892 + /// Per-command SMTP timeout in seconds. 893 + #[config(env = "MAIL_COMMAND_TIMEOUT_SECS", default = 30)] 894 + pub command_timeout_secs: u64, 895 + 896 + /// Total per-message timeout across all MX attempts in seconds. 897 + #[config(env = "MAIL_TOTAL_TIMEOUT_SECS", default = 60)] 898 + pub total_timeout_secs: u64, 899 + 900 + /// Max number of concurrent direct-MX sends. Limits the load placed 901 + /// on any single recipient MX during a backlog drain. 902 + #[config(env = "MAIL_MAX_CONCURRENT_SENDS", default = 8)] 903 + pub max_concurrent_sends: usize, 904 + 905 + /// Require STARTTLS on every MX hop. When false, TLS is 906 + /// attempted opportunistically and the session falls back to plaintext 907 + /// if the remote does not advertise STARTTLS. Set true to refuse 908 + /// plaintext delivery, at the cost of failing sends to MX hosts that 909 + /// do not support TLS. 910 + #[config(env = "MAIL_REQUIRE_TLS", default = false)] 911 + pub require_tls: bool, 912 + } 913 + 914 + #[derive(Debug, Config)] 915 + pub struct DkimConfig { 916 + /// DKIM selector. When unset, outgoing mail is not signed. 917 + #[config(env = "MAIL_DKIM_SELECTOR")] 918 + pub selector: Option<String>, 919 + 920 + /// DKIM signing domain. 921 + #[config(env = "MAIL_DKIM_DOMAIN")] 922 + pub domain: Option<String>, 923 + 924 + /// Path to the DKIM private key in PEM format. Supports RSA and 925 + /// Ed25519 keys. 926 + #[config(env = "MAIL_DKIM_KEY_PATH")] 927 + pub private_key_path: Option<String>, 760 928 } 761 929 762 930 #[derive(Debug, Config)]
+109 -4
example.toml
··· 373 373 # Default value: "Tranquil PDS" 374 374 #from_name = "Tranquil PDS" 375 375 376 - # Path to the `sendmail` binary. 376 + # HELO/EHLO name announced to remote SMTP servers. Applies to both 377 + # smarthost and direct-MX modes. Defaults to the server hostname. 378 + # 379 + # Can also be specified via environment variable `MAIL_HELO_NAME`. 380 + #helo_name = 381 + 382 + [email.smarthost] 383 + # SMTP relay host. When set, mail is delivered through this host 384 + # instead of resolving recipient MX records directly. 385 + # 386 + # Can also be specified via environment variable `MAIL_SMARTHOST_HOST`. 387 + #host = 388 + 389 + # SMTP relay port. 390 + # 391 + # Can also be specified via environment variable `MAIL_SMARTHOST_PORT`. 392 + # 393 + # Default value: 587 394 + #port = 587 395 + 396 + # SMTP authentication username. 397 + # 398 + # Can also be specified via environment variable `MAIL_SMARTHOST_USERNAME`. 399 + #username = 400 + 401 + # SMTP authentication password. 402 + # 403 + # Can also be specified via environment variable `MAIL_SMARTHOST_PASSWORD`. 404 + #password = 405 + 406 + # TLS mode. Valid values: "implicit", "starttls", "none". Setting "none" 407 + # alongside a password is rejected at startup to prevent transmitting 408 + # credentials in plaintext. 409 + # 410 + # Can also be specified via environment variable `MAIL_SMARTHOST_TLS`. 411 + # 412 + # Default value: "starttls" 413 + #tls = "starttls" 414 + 415 + # Max size of the connection pool. 416 + # 417 + # Can also be specified via environment variable `MAIL_SMARTHOST_POOL_SIZE`. 418 + # 419 + # Default value: 4 420 + #pool_size = 4 421 + 422 + # Per-command SMTP timeout in seconds. Bounds the security handshake. 423 + # 424 + # Can also be specified via environment variable `MAIL_SMARTHOST_COMMAND_TIMEOUT_SECS`. 425 + # 426 + # Default value: 30 427 + #command_timeout_secs = 30 428 + 429 + # Total per-message timeout in seconds. Wraps the entire send so a 430 + # stuck relay cannot stall the comms queue. 431 + # 432 + # Can also be specified via environment variable `MAIL_SMARTHOST_TOTAL_TIMEOUT_SECS`. 377 433 # 378 - # Can also be specified via environment variable `SENDMAIL_PATH`. 434 + # Default value: 60 435 + #total_timeout_secs = 60 436 + 437 + [email.direct_mx] 438 + # Per-command SMTP timeout in seconds. 379 439 # 380 - # Default value: "/usr/sbin/sendmail" 381 - #sendmail_path = "/usr/sbin/sendmail" 440 + # Can also be specified via environment variable `MAIL_COMMAND_TIMEOUT_SECS`. 441 + # 442 + # Default value: 30 443 + #command_timeout_secs = 30 444 + 445 + # Total per-message timeout across all MX attempts in seconds. 446 + # 447 + # Can also be specified via environment variable `MAIL_TOTAL_TIMEOUT_SECS`. 448 + # 449 + # Default value: 60 450 + #total_timeout_secs = 60 451 + 452 + # Max number of concurrent direct-MX sends. Limits the load placed 453 + # on any single recipient MX during a backlog drain. 454 + # 455 + # Can also be specified via environment variable `MAIL_MAX_CONCURRENT_SENDS`. 456 + # 457 + # Default value: 8 458 + #max_concurrent_sends = 8 459 + 460 + # Require STARTTLS on every MX hop. When false, TLS is 461 + # attempted opportunistically and the session falls back to plaintext 462 + # if the remote does not advertise STARTTLS. Set true to refuse 463 + # plaintext delivery, at the cost of failing sends to MX hosts that 464 + # do not support TLS. 465 + # 466 + # Can also be specified via environment variable `MAIL_REQUIRE_TLS`. 467 + # 468 + # Default value: false 469 + #require_tls = false 470 + 471 + [email.dkim] 472 + # DKIM selector. When unset, outgoing mail is not signed. 473 + # 474 + # Can also be specified via environment variable `MAIL_DKIM_SELECTOR`. 475 + #selector = 476 + 477 + # DKIM signing domain. 478 + # 479 + # Can also be specified via environment variable `MAIL_DKIM_DOMAIN`. 480 + #domain = 481 + 482 + # Path to the DKIM private key in PEM format. Supports RSA and 483 + # Ed25519 keys. 484 + # 485 + # Can also be specified via environment variable `MAIL_DKIM_KEY_PATH`. 486 + #private_key_path = 382 487 383 488 [discord] 384 489 # Discord bot token. When unset, Discord integration is disabled.