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-server): email config, tests, fmt #10

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/3mkuoecysgl22
+308 -82
Interdiff #2 #3
crates/tranquil-comms/tests/email_smtp.rs

This file has not been changed.

crates/tranquil-pds/src/repo_ops.rs

This file has not been changed.

crates/tranquil-pds/tests/repo_batch.rs

This file has not been changed.

-4
crates/tranquil-server/src/main.rs
··· 57 57 eprint!("{e}"); 58 58 return ExitCode::FAILURE; 59 59 } 60 - if let Err(e) = EmailSender::from_config(&config) { 61 - eprintln!("Email configuration invalid: {e}"); 62 - return ExitCode::FAILURE; 63 - } 64 60 println!("Configuration is valid."); 65 61 ExitCode::SUCCESS 66 62 }
crates/tranquil-store/src/blockstore/store.rs

This file has not been changed.

crates/tranquil-store/src/gauntlet/farm.rs

This file has not been changed.

crates/tranquil-store/src/lib.rs

This file has not been changed.

crates/tranquil-store/src/sim.rs

This file has not been changed.

crates/tranquil-store/tests/gauntlet_smoke.rs

This file has not been changed.

crates/tranquil-store/tests/sim_blockstore.rs

This file has not been changed.

crates/tranquil-store/tests/sim_eventlog.rs

This file has not been changed.

crates/tranquil-sync/src/subscribe_repos.rs

This file has not been changed.

+308 -78
crates/tranquil-config/src/lib.rs
··· 226 226 } 227 227 } 228 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 - } 229 + // -- email ----------------------------------------------------------- 230 + self.email 231 + .validate(self.server.hostname_without_port(), &mut errors); 307 232 308 233 // -- telegram --------------------------------------------------------- 309 234 if self.telegram.bot_token.is_some() && self.telegram.webhook_secret.is_none() { ··· 864 789 pub dkim: DkimConfig, 865 790 } 866 791 792 + impl EmailConfig { 793 + pub fn validate(&self, server_hostname: &str, errors: &mut Vec<String>) { 794 + match self.smarthost.tls.to_ascii_lowercase().as_str() { 795 + "implicit" | "starttls" => {} 796 + "none" => { 797 + if self.smarthost.password.is_some() { 798 + errors.push( 799 + "email.smarthost.tls = \"none\" with email.smarthost.password set \ 800 + would transmit credentials in plaintext; use \"starttls\" or \"implicit\"" 801 + .to_string(), 802 + ); 803 + } 804 + } 805 + other => errors.push(format!( 806 + "email.smarthost.tls must be \"implicit\", \"starttls\", or \"none\", got \"{other}\"" 807 + )), 808 + } 809 + 810 + let smarthost_host_set = self 811 + .smarthost 812 + .host 813 + .as_deref() 814 + .is_some_and(|h| !h.is_empty()); 815 + let username_set = self.smarthost.username.is_some(); 816 + let password_set = self.smarthost.password.is_some(); 817 + if !smarthost_host_set && (username_set || password_set) { 818 + errors.push( 819 + "email.smarthost.username or email.smarthost.password is set but \ 820 + email.smarthost.host is empty; credentials would be silently ignored" 821 + .to_string(), 822 + ); 823 + } 824 + if smarthost_host_set && username_set != password_set { 825 + errors.push( 826 + "email.smarthost.username and email.smarthost.password must both be set or \ 827 + both unset; otherwise authentication would silently degrade to anonymous" 828 + .to_string(), 829 + ); 830 + } 831 + 832 + if self.smarthost.command_timeout_secs == 0 { 833 + errors.push("email.smarthost.command_timeout_secs must be at least 1".to_string()); 834 + } 835 + if self.smarthost.total_timeout_secs == 0 { 836 + errors.push("email.smarthost.total_timeout_secs must be at least 1".to_string()); 837 + } 838 + if self.smarthost.pool_size == 0 { 839 + errors.push("email.smarthost.pool_size must be at least 1".to_string()); 840 + } 841 + 842 + if self.direct_mx.max_concurrent_sends == 0 { 843 + errors.push("email.direct_mx.max_concurrent_sends must be at least 1".to_string()); 844 + } 845 + if self.direct_mx.command_timeout_secs == 0 { 846 + errors.push("email.direct_mx.command_timeout_secs must be at least 1".to_string()); 847 + } 848 + if self.direct_mx.total_timeout_secs == 0 { 849 + errors.push("email.direct_mx.total_timeout_secs must be at least 1".to_string()); 850 + } 851 + 852 + let dkim_set = self.dkim.selector.is_some() 853 + || self.dkim.domain.is_some() 854 + || self.dkim.private_key_path.is_some(); 855 + if dkim_set { 856 + if self.dkim.selector.is_none() { 857 + errors 858 + .push("email.dkim.selector is required when any DKIM field is set".to_string()); 859 + } 860 + if self.dkim.domain.is_none() { 861 + errors.push("email.dkim.domain is required when any DKIM field is set".to_string()); 862 + } 863 + if self.dkim.private_key_path.is_none() { 864 + errors.push( 865 + "email.dkim.private_key_path is required when any DKIM field is set" 866 + .to_string(), 867 + ); 868 + } 869 + } 870 + 871 + let Some(from_address) = self.from_address.as_deref().filter(|s| !s.is_empty()) else { 872 + return; 873 + }; 874 + 875 + if !looks_like_email_address(from_address) { 876 + errors.push(format!( 877 + "email.from_address {from_address:?} is not a valid email address" 878 + )); 879 + } 880 + if self.from_name.chars().any(|c| c.is_control()) { 881 + errors.push("email.from_name must not contain control characters".to_string()); 882 + } 883 + 884 + let helo_raw = self 885 + .helo_name 886 + .as_deref() 887 + .map(str::to_string) 888 + .unwrap_or_else(|| server_hostname.to_string()); 889 + if !is_non_whitespace_token(&helo_raw) { 890 + errors.push(format!( 891 + "email HELO name {helo_raw:?} must be non-empty and contain no whitespace" 892 + )); 893 + } 894 + 895 + if smarthost_host_set { 896 + let host = self.smarthost.host.as_deref().unwrap_or(""); 897 + if !is_non_whitespace_token(host) { 898 + errors.push(format!( 899 + "email.smarthost.host {host:?} must contain no whitespace" 900 + )); 901 + } 902 + if self.smarthost.port == 0 { 903 + errors.push("email.smarthost.port must be non-zero".to_string()); 904 + } 905 + if let Some(u) = self.smarthost.username.as_deref() 906 + && u.is_empty() 907 + { 908 + errors.push("email.smarthost.username must be non-empty".to_string()); 909 + } 910 + if let Some(p) = self.smarthost.password.as_deref() 911 + && p.is_empty() 912 + { 913 + errors.push("email.smarthost.password must be non-empty".to_string()); 914 + } 915 + } 916 + 917 + if let Some(selector) = self.dkim.selector.as_deref() 918 + && !is_valid_dkim_selector(selector) 919 + { 920 + errors.push(format!( 921 + "email.dkim.selector {selector:?} must be valid subdomain syntax" 922 + )); 923 + } 924 + if let Some(domain) = self.dkim.domain.as_deref() 925 + && !is_non_whitespace_token(domain) 926 + { 927 + errors.push(format!( 928 + "email.dkim.domain {domain:?} must be non-empty and contain no whitespace" 929 + )); 930 + } 931 + if let Some(key_path) = self.dkim.private_key_path.as_deref() 932 + && key_path.trim().is_empty() 933 + { 934 + errors.push("email.dkim.private_key_path must be non-empty".to_string()); 935 + } 936 + } 937 + } 938 + 939 + fn looks_like_email_address(s: &str) -> bool { 940 + let trimmed = s.trim(); 941 + if trimmed.is_empty() || trimmed.chars().any(char::is_whitespace) { 942 + return false; 943 + } 944 + let mut parts = trimmed.split('@'); 945 + let local = parts.next().unwrap_or(""); 946 + let domain = parts.next().unwrap_or(""); 947 + parts.next().is_none() && !local.is_empty() && !domain.is_empty() && domain.contains('.') 948 + } 949 + 950 + fn is_non_whitespace_token(s: &str) -> bool { 951 + let trimmed = s.trim(); 952 + !trimmed.is_empty() && !trimmed.chars().any(char::is_whitespace) 953 + } 954 + 955 + fn is_valid_dkim_selector(s: &str) -> bool { 956 + let trimmed = s.trim(); 957 + !trimmed.is_empty() 958 + && trimmed.split('.').all(|seg| { 959 + let starts_alnum = seg 960 + .chars() 961 + .next() 962 + .is_some_and(|c| c.is_ascii_alphanumeric()); 963 + let ends_alnum = seg 964 + .chars() 965 + .next_back() 966 + .is_some_and(|c| c.is_ascii_alphanumeric()); 967 + let body_ok = seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'); 968 + starts_alnum && ends_alnum && body_ok 969 + }) 970 + } 971 + 867 972 #[derive(Debug, Config)] 868 973 pub struct SmarthostConfig { 869 974 /// SMTP relay host. When set, mail is delivered through this host ··· 1440 1545 result 1441 1546 ); 1442 1547 } 1548 + 1549 + #[test] 1550 + fn email_address_predicate_accepts_typical_addresses() { 1551 + assert!(looks_like_email_address("alice@nel.pet")); 1552 + assert!(looks_like_email_address("a.b+tag@example.co.uk")); 1553 + } 1554 + 1555 + #[test] 1556 + fn email_address_predicate_rejects_malformed() { 1557 + assert!(!looks_like_email_address("")); 1558 + assert!(!looks_like_email_address("no-at-sign")); 1559 + assert!(!looks_like_email_address("@nel.pet")); 1560 + assert!(!looks_like_email_address("alice@")); 1561 + assert!(!looks_like_email_address("alice@nel")); 1562 + assert!(!looks_like_email_address("a@b@c.com")); 1563 + assert!(!looks_like_email_address("alice @nel.pet")); 1564 + } 1565 + 1566 + #[test] 1567 + fn dkim_selector_predicate_matches_subdomain_syntax() { 1568 + assert!(is_valid_dkim_selector("default")); 1569 + assert!(is_valid_dkim_selector("s2024-q1")); 1570 + assert!(is_valid_dkim_selector("mailo-2024.nel.pet")); 1571 + assert!(!is_valid_dkim_selector("")); 1572 + assert!(!is_valid_dkim_selector("a..b")); 1573 + assert!(!is_valid_dkim_selector("-leading")); 1574 + assert!(!is_valid_dkim_selector("trailing-")); 1575 + assert!(!is_valid_dkim_selector("s_under")); 1576 + } 1577 + 1578 + #[test] 1579 + fn email_validate_disabled_when_from_address_unset() { 1580 + let cfg = email_config_for_test(EmailOverrides::default()); 1581 + let mut errors = Vec::new(); 1582 + cfg.validate("test.local", &mut errors); 1583 + assert!(errors.is_empty(), "expected no errors, got {errors:?}"); 1584 + } 1585 + 1586 + #[test] 1587 + fn email_validate_rejects_bad_from_address() { 1588 + let cfg = email_config_for_test(EmailOverrides { 1589 + from_address: Some("not-an-email"), 1590 + ..Default::default() 1591 + }); 1592 + let mut errors = Vec::new(); 1593 + cfg.validate("test.local", &mut errors); 1594 + assert!( 1595 + errors.iter().any(|e| e.contains("from_address")), 1596 + "expected from_address error, got {errors:?}" 1597 + ); 1598 + } 1599 + 1600 + #[test] 1601 + fn email_validate_rejects_smarthost_with_bad_credentials() { 1602 + let cfg = email_config_for_test(EmailOverrides { 1603 + from_address: Some("alice@nel.pet"), 1604 + smarthost_host: Some("smtp.nel.pet"), 1605 + smarthost_username: Some(""), 1606 + smarthost_password: Some("hunter2"), 1607 + ..Default::default() 1608 + }); 1609 + let mut errors = Vec::new(); 1610 + cfg.validate("test.local", &mut errors); 1611 + assert!( 1612 + errors.iter().any(|e| e.contains("smarthost.username")), 1613 + "expected smarthost.username error, got {errors:?}" 1614 + ); 1615 + } 1616 + 1617 + #[test] 1618 + fn email_validate_rejects_bad_dkim_selector() { 1619 + let cfg = email_config_for_test(EmailOverrides { 1620 + from_address: Some("alice@nel.pet"), 1621 + dkim_selector: Some("-bad"), 1622 + dkim_domain: Some("nel.pet"), 1623 + dkim_key_path: Some("/etc/dkim.key"), 1624 + ..Default::default() 1625 + }); 1626 + let mut errors = Vec::new(); 1627 + cfg.validate("test.local", &mut errors); 1628 + assert!( 1629 + errors.iter().any(|e| e.contains("dkim.selector")), 1630 + "expected dkim.selector error, got {errors:?}" 1631 + ); 1632 + } 1633 + 1634 + #[derive(Default)] 1635 + struct EmailOverrides { 1636 + from_address: Option<&'static str>, 1637 + smarthost_host: Option<&'static str>, 1638 + smarthost_username: Option<&'static str>, 1639 + smarthost_password: Option<&'static str>, 1640 + dkim_selector: Option<&'static str>, 1641 + dkim_domain: Option<&'static str>, 1642 + dkim_key_path: Option<&'static str>, 1643 + } 1644 + 1645 + fn email_config_for_test(o: EmailOverrides) -> EmailConfig { 1646 + EmailConfig { 1647 + from_address: o.from_address.map(str::to_string), 1648 + from_name: "Tranquil PDS".to_string(), 1649 + helo_name: None, 1650 + smarthost: SmarthostConfig { 1651 + host: o.smarthost_host.map(str::to_string), 1652 + port: 587, 1653 + username: o.smarthost_username.map(str::to_string), 1654 + password: o.smarthost_password.map(str::to_string), 1655 + tls: "starttls".to_string(), 1656 + pool_size: 4, 1657 + command_timeout_secs: 30, 1658 + total_timeout_secs: 60, 1659 + }, 1660 + direct_mx: DirectMxConfig { 1661 + command_timeout_secs: 30, 1662 + total_timeout_secs: 60, 1663 + max_concurrent_sends: 8, 1664 + require_tls: false, 1665 + }, 1666 + dkim: DkimConfig { 1667 + selector: o.dkim_selector.map(str::to_string), 1668 + domain: o.dkim_domain.map(str::to_string), 1669 + private_key_path: o.dkim_key_path.map(str::to_string), 1670 + }, 1671 + } 1672 + } 1443 1673 }

History

4 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
feat(tranquil-server): email config, tests, fmt
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-server): email config, tests, fmt
expand 0 comments
1 commit
expand
feat(tranquil-server): email config, tests, fmt
expand 0 comments
1 commit
expand
feat(tranquil-server): email config, tests, fmt
expand 0 comments