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 #6

open opened by oyster.cafe targeting main from feat/inline-emailing
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mkuoecyrw422
+781 -38
Diff #3
+8
.config/nextest.toml
··· 68 68 filter = "package(tranquil-signal)" 69 69 test-group = "serial-env-tests" 70 70 71 + [[profile.default.overrides]] 72 + filter = "package(tranquil-config)" 73 + test-group = "serial-env-tests" 74 + 71 75 [[profile.default.overrides]] 72 76 filter = "binary(whole_story)" 73 77 test-group = "heavy-load-tests" ··· 118 122 filter = "package(tranquil-signal)" 119 123 test-group = "serial-env-tests" 120 124 125 + [[profile.ci.overrides]] 126 + filter = "package(tranquil-config)" 127 + test-group = "serial-env-tests" 128 + 121 129 [[profile.ci.overrides]] 122 130 filter = "binary(whole_story)" 123 131 test-group = "heavy-load-tests"
+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", ··· 2216 2216 "zeroize", 2217 2217 ] 2218 2218 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 + 2219 2235 [[package]] 2220 2236 name = "embedded-io" 2221 2237 version = "0.4.0" ··· 3780 3796 source = "registry+https://github.com/rust-lang/crates.io-index" 3781 3797 checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" 3782 3798 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 + 3783 3830 [[package]] 3784 3831 name = "libc" 3785 3832 version = "0.2.183" ··· 4409 4456 "minimal-lexical", 4410 4457 ] 4411 4458 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 + 4412 4468 [[package]] 4413 4469 name = "nonzero_ext" 4414 4470 version = "0.3.0" ··· 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", ··· 5427 5483 "proc-macro2", 5428 5484 ] 5429 5485 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 + 5430 5492 [[package]] 5431 5493 name = "r-efi" 5432 5494 version = "5.3.0" ··· 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]] ··· 6050 6112 "zeroize", 6051 6113 ] 6052 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", 6122 + "zeroize", 6123 + ] 6124 + 6053 6125 [[package]] 6054 6126 name = "security-framework" 6055 6127 version = "3.7.0" ··· 7455 7527 7456 7528 [[package]] 7457 7529 name = "tranquil-api" 7458 - version = "0.5.7" 7530 + version = "0.6.0" 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.6.0" 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.6.0" 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.6.0" 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.6.0" 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.6.0" 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.6.0" 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.6.0" 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.6.0" 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.6.0" 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.6.0" 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.6.0" 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.6.0" 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.6.0" 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.6.0" 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.6.0" 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.6.0" 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.6.0" 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.6.0" 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.6.0" 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.6.0" 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.6.0" 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.6.0" 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"] }
+291
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(raw: &str, lowercase: bool, strip_trailing_dot: bool) -> Result<String, ParseError> { 16 + let mut s = raw.trim(); 17 + if strip_trailing_dot { 18 + s = s.trim_end_matches('.'); 19 + } 20 + match s { 21 + "" => Err(ParseError::Empty), 22 + _ if s.chars().any(char::is_whitespace) => Err(ParseError::InvalidChar(' ')), 23 + _ => Ok(match lowercase { 24 + true => s.to_lowercase(), 25 + false => s.to_string(), 26 + }), 27 + } 28 + } 29 + 30 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 31 + pub struct SmtpHost(String); 32 + 33 + impl SmtpHost { 34 + pub fn parse(raw: &str) -> Result<Self, ParseError> { 35 + parse_token(raw, true, false).map(Self) 36 + } 37 + 38 + pub fn as_str(&self) -> &str { 39 + &self.0 40 + } 41 + } 42 + 43 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 44 + pub struct SmtpPort(u16); 45 + 46 + impl SmtpPort { 47 + pub fn parse(raw: u16) -> Result<Self, ParseError> { 48 + match raw { 49 + 0 => Err(ParseError::Zero("smtp port")), 50 + n => Ok(Self(n)), 51 + } 52 + } 53 + 54 + pub fn as_u16(self) -> u16 { 55 + self.0 56 + } 57 + } 58 + 59 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 60 + pub struct HeloName(String); 61 + 62 + impl HeloName { 63 + pub fn parse(raw: &str) -> Result<Self, ParseError> { 64 + parse_token(raw, false, false).map(Self) 65 + } 66 + 67 + pub fn as_str(&self) -> &str { 68 + &self.0 69 + } 70 + 71 + pub fn into_inner(self) -> String { 72 + self.0 73 + } 74 + } 75 + 76 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 77 + pub struct EmailDomain(String); 78 + 79 + impl EmailDomain { 80 + pub fn parse(raw: &str) -> Result<Self, ParseError> { 81 + parse_token(raw, true, true).map(Self) 82 + } 83 + 84 + pub fn as_str(&self) -> &str { 85 + &self.0 86 + } 87 + 88 + pub fn into_inner(self) -> String { 89 + self.0 90 + } 91 + } 92 + 93 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 94 + pub struct MxHost(String); 95 + 96 + impl MxHost { 97 + pub fn parse(raw: &str) -> Result<Self, ParseError> { 98 + parse_token(raw, true, true).map(Self) 99 + } 100 + 101 + pub fn as_str(&self) -> &str { 102 + &self.0 103 + } 104 + } 105 + 106 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] 107 + pub struct MxPriority(u16); 108 + 109 + impl MxPriority { 110 + pub fn new(value: u16) -> Self { 111 + Self(value) 112 + } 113 + 114 + pub fn as_u16(self) -> u16 { 115 + self.0 116 + } 117 + } 118 + 119 + #[derive(Debug, Clone)] 120 + pub struct MxRecord { 121 + pub priority: MxPriority, 122 + pub host: MxHost, 123 + } 124 + 125 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 126 + pub struct DkimSelector(String); 127 + 128 + impl DkimSelector { 129 + pub fn parse(raw: &str) -> Result<Self, ParseError> { 130 + let trimmed = raw.trim(); 131 + let valid = !trimmed.is_empty() && trimmed.split('.').all(valid_subdomain); 132 + match valid { 133 + true => Ok(Self(trimmed.to_string())), 134 + false => Err(ParseError::InvalidChar('?')), 135 + } 136 + } 137 + 138 + pub fn into_inner(self) -> String { 139 + self.0 140 + } 141 + } 142 + 143 + fn valid_subdomain(seg: &str) -> bool { 144 + let starts_alnum = seg 145 + .chars() 146 + .next() 147 + .is_some_and(|c| c.is_ascii_alphanumeric()); 148 + let ends_alnum = seg 149 + .chars() 150 + .next_back() 151 + .is_some_and(|c| c.is_ascii_alphanumeric()); 152 + let body_ok = seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'); 153 + starts_alnum && ends_alnum && body_ok 154 + } 155 + 156 + #[derive(Debug, Clone)] 157 + pub struct DkimKeyPath(PathBuf); 158 + 159 + impl DkimKeyPath { 160 + pub fn parse(raw: &str) -> Result<Self, ParseError> { 161 + let trimmed = raw.trim(); 162 + match trimmed.is_empty() { 163 + true => Err(ParseError::Empty), 164 + false => Ok(Self(PathBuf::from(trimmed))), 165 + } 166 + } 167 + 168 + pub fn as_path(&self) -> &std::path::Path { 169 + &self.0 170 + } 171 + } 172 + 173 + #[derive(Debug, Clone, PartialEq, Eq)] 174 + pub struct SmtpUsername(String); 175 + 176 + impl SmtpUsername { 177 + pub fn parse(raw: &str) -> Result<Self, ParseError> { 178 + match raw.is_empty() { 179 + true => Err(ParseError::Empty), 180 + false => Ok(Self(raw.to_string())), 181 + } 182 + } 183 + 184 + pub fn into_inner(self) -> String { 185 + self.0 186 + } 187 + } 188 + 189 + #[derive(Clone)] 190 + pub struct SmtpPassword(secrecy::SecretString); 191 + 192 + impl SmtpPassword { 193 + pub fn parse(raw: &str) -> Result<Self, ParseError> { 194 + match raw.is_empty() { 195 + true => Err(ParseError::Empty), 196 + false => Ok(Self(secrecy::SecretString::from(raw.to_string()))), 197 + } 198 + } 199 + 200 + pub fn expose(&self) -> &str { 201 + use secrecy::ExposeSecret; 202 + self.0.expose_secret() 203 + } 204 + } 205 + 206 + impl std::fmt::Debug for SmtpPassword { 207 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 208 + f.write_str("SmtpPassword(***)") 209 + } 210 + } 211 + 212 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 213 + pub enum TlsMode { 214 + Implicit, 215 + Starttls, 216 + None, 217 + } 218 + 219 + impl TlsMode { 220 + pub fn parse(raw: &str) -> Result<Self, ParseError> { 221 + match raw.to_ascii_lowercase().as_str() { 222 + "implicit" => Ok(Self::Implicit), 223 + "starttls" => Ok(Self::Starttls), 224 + "none" => Ok(Self::None), 225 + other => Err(ParseError::InvalidTlsMode(other.to_string())), 226 + } 227 + } 228 + } 229 + 230 + #[cfg(test)] 231 + mod tests { 232 + use super::*; 233 + 234 + #[test] 235 + fn smtp_host_lowercases_and_trims() { 236 + let h = SmtpHost::parse(" SMTP.NEL.PET ").unwrap(); 237 + assert_eq!(h.as_str(), "smtp.nel.pet"); 238 + } 239 + 240 + #[test] 241 + fn smtp_host_rejects_whitespace() { 242 + assert!(SmtpHost::parse("a b").is_err()); 243 + } 244 + 245 + #[test] 246 + fn smtp_host_rejects_empty() { 247 + assert!(SmtpHost::parse("").is_err()); 248 + assert!(SmtpHost::parse(" ").is_err()); 249 + } 250 + 251 + #[test] 252 + fn smtp_port_rejects_zero() { 253 + assert!(SmtpPort::parse(0).is_err()); 254 + assert_eq!(SmtpPort::parse(587).unwrap().as_u16(), 587); 255 + } 256 + 257 + #[test] 258 + fn email_domain_strips_trailing_dot() { 259 + assert_eq!(EmailDomain::parse("Nel.pet.").unwrap().as_str(), "nel.pet"); 260 + } 261 + 262 + #[test] 263 + fn dkim_selector_validates() { 264 + assert!(DkimSelector::parse("default").is_ok()); 265 + assert!(DkimSelector::parse("s1.nel.pet").is_ok()); 266 + assert!(DkimSelector::parse("s2024-q1").is_ok()); 267 + assert!(DkimSelector::parse("mailo-2024.nel.pet").is_ok()); 268 + assert!(DkimSelector::parse("a-b").is_ok()); 269 + assert!(DkimSelector::parse("").is_err()); 270 + assert!(DkimSelector::parse("a..b").is_err()); 271 + assert!(DkimSelector::parse("-leading").is_err()); 272 + assert!(DkimSelector::parse("trailing-").is_err()); 273 + assert!(DkimSelector::parse("s_under").is_err()); 274 + } 275 + 276 + #[test] 277 + fn tls_mode_parses_known_modes() { 278 + assert_eq!(TlsMode::parse("STARTTLS").unwrap(), TlsMode::Starttls); 279 + assert_eq!(TlsMode::parse("implicit").unwrap(), TlsMode::Implicit); 280 + assert_eq!(TlsMode::parse("none").unwrap(), TlsMode::None); 281 + assert!(TlsMode::parse("garbage").is_err()); 282 + } 283 + 284 + #[test] 285 + fn smtp_password_redacts_in_debug() { 286 + let p = SmtpPassword::parse("hunter2").unwrap(); 287 + let dbg = format!("{:?}", p); 288 + assert_eq!(dbg, "SmtpPassword(***)"); 289 + assert!(!dbg.contains("hunter2")); 290 + } 291 + }
+248 -3
crates/tranquil-config/src/lib.rs
··· 5 5 6 6 static CONFIG: OnceLock<TranquilConfig> = OnceLock::new(); 7 7 8 + const REMOVED_ENV_VARS: &[(&str, &str)] = &[( 9 + "SENDMAIL_PATH", 10 + "the sendmail-binary transport was replaced with native SMTP. \ 11 + Configure MAIL_SMARTHOST_HOST for relay delivery, or leave it unset to \ 12 + deliver directly via recipient MX records. See example.toml for the full \ 13 + MAIL_* surface.", 14 + )]; 15 + 8 16 /// Errors discovered during configuration validation. 9 17 #[derive(Debug)] 10 18 pub struct ConfigError { ··· 162 170 pub fn validate(&self, ignore_secrets: bool) -> Result<(), ConfigError> { 163 171 let mut errors = Vec::new(); 164 172 173 + // -- removed config --------------------------------------------------- 174 + errors.extend( 175 + REMOVED_ENV_VARS 176 + .iter() 177 + .filter(|(var, _)| std::env::var_os(var).is_some()) 178 + .map(|(var, guidance)| format!("{var} is no longer supported: {guidance}")), 179 + ); 180 + 165 181 // -- secrets ---------------------------------------------------------- 166 182 if !ignore_secrets && !self.secrets.allow_insecure && !cfg!(test) { 167 183 if let Some(ref s) = self.secrets.jwt_secret { ··· 210 226 } 211 227 } 212 228 229 + // -- email smarthost -------------------------------------------------- 230 + match self.email.smarthost.tls.to_ascii_lowercase().as_str() { 231 + "implicit" | "starttls" => {} 232 + "none" => { 233 + if self.email.smarthost.password.is_some() { 234 + errors.push( 235 + "email.smarthost.tls = \"none\" with email.smarthost.password set \ 236 + would transmit credentials in plaintext; use \"starttls\" or \"implicit\"" 237 + .to_string(), 238 + ); 239 + } 240 + } 241 + other => errors.push(format!( 242 + "email.smarthost.tls must be \"implicit\", \"starttls\", or \"none\", got \"{other}\"" 243 + )), 244 + } 245 + 246 + let smarthost_host_set = self 247 + .email 248 + .smarthost 249 + .host 250 + .as_deref() 251 + .is_some_and(|h| !h.is_empty()); 252 + let username_set = self.email.smarthost.username.is_some(); 253 + let password_set = self.email.smarthost.password.is_some(); 254 + if !smarthost_host_set && (username_set || password_set) { 255 + errors.push( 256 + "email.smarthost.username or email.smarthost.password is set but \ 257 + email.smarthost.host is empty; credentials would be silently ignored" 258 + .to_string(), 259 + ); 260 + } 261 + if smarthost_host_set && username_set != password_set { 262 + errors.push( 263 + "email.smarthost.username and email.smarthost.password must both be set or \ 264 + both unset; otherwise authentication would silently degrade to anonymous" 265 + .to_string(), 266 + ); 267 + } 268 + 269 + if self.email.smarthost.command_timeout_secs == 0 { 270 + errors.push("email.smarthost.command_timeout_secs must be at least 1".to_string()); 271 + } 272 + if self.email.smarthost.total_timeout_secs == 0 { 273 + errors.push("email.smarthost.total_timeout_secs must be at least 1".to_string()); 274 + } 275 + if self.email.smarthost.pool_size == 0 { 276 + errors.push("email.smarthost.pool_size must be at least 1".to_string()); 277 + } 278 + 279 + if self.email.direct_mx.max_concurrent_sends == 0 { 280 + errors.push("email.direct_mx.max_concurrent_sends must be at least 1".to_string()); 281 + } 282 + if self.email.direct_mx.command_timeout_secs == 0 { 283 + errors.push("email.direct_mx.command_timeout_secs must be at least 1".to_string()); 284 + } 285 + if self.email.direct_mx.total_timeout_secs == 0 { 286 + errors.push("email.direct_mx.total_timeout_secs must be at least 1".to_string()); 287 + } 288 + 289 + let dkim_set = self.email.dkim.selector.is_some() 290 + || self.email.dkim.domain.is_some() 291 + || self.email.dkim.private_key_path.is_some(); 292 + if dkim_set { 293 + if self.email.dkim.selector.is_none() { 294 + errors 295 + .push("email.dkim.selector is required when any DKIM field is set".to_string()); 296 + } 297 + if self.email.dkim.domain.is_none() { 298 + errors.push("email.dkim.domain is required when any DKIM field is set".to_string()); 299 + } 300 + if self.email.dkim.private_key_path.is_none() { 301 + errors.push( 302 + "email.dkim.private_key_path is required when any DKIM field is set" 303 + .to_string(), 304 + ); 305 + } 306 + } 307 + 213 308 // -- telegram --------------------------------------------------------- 214 309 if self.telegram.bot_token.is_some() && self.telegram.webhook_secret.is_none() { 215 310 errors.push( ··· 754 849 #[config(env = "MAIL_FROM_NAME", default = "Tranquil PDS")] 755 850 pub from_name: String, 756 851 757 - /// Path to the `sendmail` binary. 758 - #[config(env = "SENDMAIL_PATH", default = "/usr/sbin/sendmail")] 759 - pub sendmail_path: String, 852 + /// HELO/EHLO name announced to remote SMTP servers. Applies to both 853 + /// smarthost and direct-MX modes. Defaults to the server hostname. 854 + #[config(env = "MAIL_HELO_NAME")] 855 + pub helo_name: Option<String>, 856 + 857 + #[config(nested)] 858 + pub smarthost: SmarthostConfig, 859 + 860 + #[config(nested)] 861 + pub direct_mx: DirectMxConfig, 862 + 863 + #[config(nested)] 864 + pub dkim: DkimConfig, 865 + } 866 + 867 + #[derive(Debug, Config)] 868 + pub struct SmarthostConfig { 869 + /// SMTP relay host. When set, mail is delivered through this host 870 + /// instead of resolving recipient MX records directly. 871 + #[config(env = "MAIL_SMARTHOST_HOST")] 872 + pub host: Option<String>, 873 + 874 + /// SMTP relay port. 875 + #[config(env = "MAIL_SMARTHOST_PORT", default = 587)] 876 + pub port: u16, 877 + 878 + /// SMTP authentication username. 879 + #[config(env = "MAIL_SMARTHOST_USERNAME")] 880 + pub username: Option<String>, 881 + 882 + /// SMTP authentication password. 883 + #[config(env = "MAIL_SMARTHOST_PASSWORD")] 884 + pub password: Option<String>, 885 + 886 + /// TLS mode. Valid values: "implicit", "starttls", "none". Setting "none" 887 + /// alongside a password is rejected at startup to prevent transmitting 888 + /// credentials in plaintext. 889 + #[config(env = "MAIL_SMARTHOST_TLS", default = "starttls")] 890 + pub tls: String, 891 + 892 + /// Max size of the connection pool. 893 + #[config(env = "MAIL_SMARTHOST_POOL_SIZE", default = 4)] 894 + pub pool_size: u32, 895 + 896 + /// Per-command SMTP timeout in seconds. Bounds the security handshake. 897 + #[config(env = "MAIL_SMARTHOST_COMMAND_TIMEOUT_SECS", default = 30)] 898 + pub command_timeout_secs: u64, 899 + 900 + /// Total per-message timeout in seconds. Wraps the entire send so a 901 + /// stuck relay cannot stall the comms queue. 902 + #[config(env = "MAIL_SMARTHOST_TOTAL_TIMEOUT_SECS", default = 60)] 903 + pub total_timeout_secs: u64, 904 + } 905 + 906 + #[derive(Debug, Config)] 907 + pub struct DirectMxConfig { 908 + /// Per-command SMTP timeout in seconds. 909 + #[config(env = "MAIL_COMMAND_TIMEOUT_SECS", default = 30)] 910 + pub command_timeout_secs: u64, 911 + 912 + /// Total per-message timeout across all MX attempts in seconds. 913 + #[config(env = "MAIL_TOTAL_TIMEOUT_SECS", default = 60)] 914 + pub total_timeout_secs: u64, 915 + 916 + /// Max number of concurrent direct-MX sends. Limits the load placed 917 + /// on any single recipient MX during a backlog drain. 918 + #[config(env = "MAIL_MAX_CONCURRENT_SENDS", default = 8)] 919 + pub max_concurrent_sends: usize, 920 + 921 + /// Require STARTTLS on every MX hop. When false, TLS is 922 + /// attempted opportunistically and the session falls back to plaintext 923 + /// if the remote does not advertise STARTTLS. Set true to refuse 924 + /// plaintext delivery, at the cost of failing sends to MX hosts that 925 + /// do not support TLS. 926 + #[config(env = "MAIL_REQUIRE_TLS", default = false)] 927 + pub require_tls: bool, 928 + } 929 + 930 + #[derive(Debug, Config)] 931 + pub struct DkimConfig { 932 + /// DKIM selector. When unset, outgoing mail is not signed. 933 + #[config(env = "MAIL_DKIM_SELECTOR")] 934 + pub selector: Option<String>, 935 + 936 + /// DKIM signing domain. 937 + #[config(env = "MAIL_DKIM_DOMAIN")] 938 + pub domain: Option<String>, 939 + 940 + /// Path to the DKIM private key in PEM format. Supports RSA and 941 + /// Ed25519 keys. 942 + #[config(env = "MAIL_DKIM_KEY_PATH")] 943 + pub private_key_path: Option<String>, 760 944 } 761 945 762 946 #[derive(Debug, Config)] ··· 1196 1380 pub fn template() -> String { 1197 1381 confique::toml::template::<TranquilConfig>(confique::toml::FormatOptions::default()) 1198 1382 } 1383 + 1384 + #[cfg(test)] 1385 + mod tests { 1386 + use super::*; 1387 + 1388 + fn seed_required_env() { 1389 + let required = [ 1390 + ("PDS_HOSTNAME", "test.local"), 1391 + ("DATABASE_URL", "postgres://localhost/test"), 1392 + ("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", "1"), 1393 + ("INVITE_CODE_REQUIRED", "false"), 1394 + ("ENABLE_PDS_HOSTED_DID_WEB", "true"), 1395 + ("TRANQUIL_LEXICON_OFFLINE", "1"), 1396 + ]; 1397 + required 1398 + .iter() 1399 + .filter(|(k, _)| std::env::var_os(k).is_none()) 1400 + .for_each(|(k, v)| unsafe { std::env::set_var(k, v) }); 1401 + } 1402 + 1403 + #[test] 1404 + fn serial_validate_rejects_legacy_sendmail_path() { 1405 + seed_required_env(); 1406 + unsafe { std::env::set_var("SENDMAIL_PATH", "/usr/sbin/sendmail") }; 1407 + let config = TranquilConfig::builder() 1408 + .env() 1409 + .load() 1410 + .expect("load fresh config"); 1411 + let result = config.validate(true); 1412 + unsafe { std::env::remove_var("SENDMAIL_PATH") }; 1413 + 1414 + let err = result.expect_err("validate must reject SENDMAIL_PATH"); 1415 + let mentions_sendmail = err.errors.iter().any(|e| e.contains("SENDMAIL_PATH")); 1416 + assert!( 1417 + mentions_sendmail, 1418 + "errors did not mention SENDMAIL_PATH: {:?}", 1419 + err.errors 1420 + ); 1421 + } 1422 + 1423 + #[test] 1424 + fn serial_validate_passes_when_no_legacy_env_set() { 1425 + seed_required_env(); 1426 + unsafe { std::env::remove_var("SENDMAIL_PATH") }; 1427 + let config = TranquilConfig::builder() 1428 + .env() 1429 + .load() 1430 + .expect("load fresh config"); 1431 + let result = config.validate(true); 1432 + let leaked_legacy = result 1433 + .as_ref() 1434 + .err() 1435 + .map(|e| e.errors.iter().any(|s| s.contains("SENDMAIL_PATH"))) 1436 + .unwrap_or(false); 1437 + assert!( 1438 + !leaked_legacy, 1439 + "validate spuriously flagged SENDMAIL_PATH when unset: {:?}", 1440 + result 1441 + ); 1442 + } 1443 + }
+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. 377 378 # 378 - # Can also be specified via environment variable `SENDMAIL_PATH`. 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`. 433 + # 434 + # Default value: 60 435 + #total_timeout_secs = 60 436 + 437 + [email.direct_mx] 438 + # Per-command SMTP timeout in seconds. 439 + # 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. 379 484 # 380 - # Default value: "/usr/sbin/sendmail" 381 - #sendmail_path = "/usr/sbin/sendmail" 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.

History

4 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
feat(tranquil-comms): prework for email
merge conflicts detected
expand
  • .config/nextest.toml:68
  • Cargo.lock:9
  • Cargo.toml:26
  • crates/tranquil-comms/Cargo.toml:10
  • crates/tranquil-config/src/lib.rs:5
  • example.toml:373
expand 0 comments
1 commit
expand
feat(tranquil-comms): prework for email
expand 0 comments
1 commit
expand
feat(tranquil-comms): prework for email
expand 0 comments
1 commit
expand
feat(tranquil-comms): prework for email
expand 0 comments