Lewis: May this revision serve well! lu5a@proton.me
crates/tranquil-comms/tests/email_smtp.rs
crates/tranquil-comms/tests/email_smtp.rs
This file has not been changed.
crates/tranquil-pds/src/repo_ops.rs
crates/tranquil-pds/src/repo_ops.rs
This file has not been changed.
crates/tranquil-pds/tests/repo_batch.rs
crates/tranquil-pds/tests/repo_batch.rs
This file has not been changed.
-4
crates/tranquil-server/src/main.rs
-4
crates/tranquil-server/src/main.rs
crates/tranquil-store/src/blockstore/store.rs
crates/tranquil-store/src/blockstore/store.rs
This file has not been changed.
crates/tranquil-store/src/gauntlet/farm.rs
crates/tranquil-store/src/gauntlet/farm.rs
This file has not been changed.
crates/tranquil-store/src/lib.rs
crates/tranquil-store/src/lib.rs
This file has not been changed.
crates/tranquil-store/src/sim.rs
crates/tranquil-store/src/sim.rs
This file has not been changed.
crates/tranquil-store/tests/gauntlet_smoke.rs
crates/tranquil-store/tests/gauntlet_smoke.rs
This file has not been changed.
crates/tranquil-store/tests/sim_blockstore.rs
crates/tranquil-store/tests/sim_blockstore.rs
This file has not been changed.
crates/tranquil-store/tests/sim_eventlog.rs
crates/tranquil-store/tests/sim_eventlog.rs
This file has not been changed.
crates/tranquil-sync/src/subscribe_repos.rs
crates/tranquil-sync/src/subscribe_repos.rs
This file has not been changed.
+308
-78
crates/tranquil-config/src/lib.rs
+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
oyster.cafe
submitted
#3
1 commit
expand
collapse
feat(tranquil-server): email config, tests, fmt
Lewis: May this revision serve well! <lu5a@proton.me>
merge conflicts detected
expand
collapse
expand
collapse
- .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
oyster.cafe
submitted
#2
1 commit
expand
collapse
feat(tranquil-server): email config, tests, fmt
Lewis: May this revision serve well! <lu5a@proton.me>
expand 0 comments
oyster.cafe
submitted
#1
1 commit
expand
collapse
feat(tranquil-server): email config, tests, fmt
Lewis: May this revision serve well! <lu5a@proton.me>
expand 0 comments
oyster.cafe
submitted
#0
1 commit
expand
collapse
feat(tranquil-server): email config, tests, fmt
Lewis: May this revision serve well! <lu5a@proton.me>