Lewis: May this revision serve well! lu5a@proton.me
+781
-38
Diff
round #3
+8
.config/nextest.toml
+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
+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
+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
+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
+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
+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
+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
oyster.cafe
submitted
#3
1 commit
expand
collapse
feat(tranquil-comms): prework for email
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-comms): prework for email
Lewis: May this revision serve well! <lu5a@proton.me>
expand 0 comments
oyster.cafe
submitted
#1
1 commit
expand
collapse
feat(tranquil-comms): prework for email
Lewis: May this revision serve well! <lu5a@proton.me>
expand 0 comments
oyster.cafe
submitted
#0
1 commit
expand
collapse
feat(tranquil-comms): prework for email
Lewis: May this revision serve well! <lu5a@proton.me>