Lewis: May this revision serve well! lu5a@proton.me
+703
-38
Diff
round #0
+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.5.8"
7459
7531
dependencies = [
7460
7532
"anyhow",
7461
7533
"axum",
···
7506
7578
7507
7579
[[package]]
7508
7580
name = "tranquil-auth"
7509
-
version = "0.5.7"
7581
+
version = "0.5.8"
7510
7582
dependencies = [
7511
7583
"anyhow",
7512
7584
"base32",
···
7529
7601
7530
7602
[[package]]
7531
7603
name = "tranquil-cache"
7532
-
version = "0.5.7"
7604
+
version = "0.5.8"
7533
7605
dependencies = [
7534
7606
"async-trait",
7535
7607
"base64 0.22.1",
···
7543
7615
7544
7616
[[package]]
7545
7617
name = "tranquil-comms"
7546
-
version = "0.5.7"
7618
+
version = "0.5.8"
7547
7619
dependencies = [
7548
7620
"async-trait",
7549
7621
"base64 0.22.1",
7622
+
"chrono",
7623
+
"ed25519-dalek",
7624
+
"futures",
7625
+
"hickory-resolver",
7626
+
"lettre",
7627
+
"rand 0.8.5",
7550
7628
"reqwest",
7629
+
"rsa",
7630
+
"secrecy",
7551
7631
"serde_json",
7552
7632
"sqlx",
7553
7633
"thiserror 2.0.18",
···
7561
7641
7562
7642
[[package]]
7563
7643
name = "tranquil-config"
7564
-
version = "0.5.7"
7644
+
version = "0.5.8"
7565
7645
dependencies = [
7566
7646
"confique",
7567
7647
"serde",
···
7569
7649
7570
7650
[[package]]
7571
7651
name = "tranquil-crypto"
7572
-
version = "0.5.7"
7652
+
version = "0.5.8"
7573
7653
dependencies = [
7574
7654
"aes-gcm",
7575
7655
"base64 0.22.1",
···
7585
7665
7586
7666
[[package]]
7587
7667
name = "tranquil-db"
7588
-
version = "0.5.7"
7668
+
version = "0.5.8"
7589
7669
dependencies = [
7590
7670
"async-trait",
7591
7671
"chrono",
···
7602
7682
7603
7683
[[package]]
7604
7684
name = "tranquil-db-traits"
7605
-
version = "0.5.7"
7685
+
version = "0.5.8"
7606
7686
dependencies = [
7607
7687
"async-trait",
7608
7688
"base64 0.22.1",
···
7618
7698
7619
7699
[[package]]
7620
7700
name = "tranquil-infra"
7621
-
version = "0.5.7"
7701
+
version = "0.5.8"
7622
7702
dependencies = [
7623
7703
"async-trait",
7624
7704
"bytes",
···
7629
7709
7630
7710
[[package]]
7631
7711
name = "tranquil-lexicon"
7632
-
version = "0.5.7"
7712
+
version = "0.5.8"
7633
7713
dependencies = [
7634
7714
"chrono",
7635
7715
"futures",
···
7648
7728
7649
7729
[[package]]
7650
7730
name = "tranquil-oauth"
7651
-
version = "0.5.7"
7731
+
version = "0.5.8"
7652
7732
dependencies = [
7653
7733
"anyhow",
7654
7734
"axum",
···
7671
7751
7672
7752
[[package]]
7673
7753
name = "tranquil-oauth-server"
7674
-
version = "0.5.7"
7754
+
version = "0.5.8"
7675
7755
dependencies = [
7676
7756
"axum",
7677
7757
"base64 0.22.1",
···
7704
7784
7705
7785
[[package]]
7706
7786
name = "tranquil-pds"
7707
-
version = "0.5.7"
7787
+
version = "0.5.8"
7708
7788
dependencies = [
7709
7789
"aes-gcm",
7710
7790
"anyhow",
···
7796
7876
7797
7877
[[package]]
7798
7878
name = "tranquil-repo"
7799
-
version = "0.5.7"
7879
+
version = "0.5.8"
7800
7880
dependencies = [
7801
7881
"bytes",
7802
7882
"cid",
···
7808
7888
7809
7889
[[package]]
7810
7890
name = "tranquil-ripple"
7811
-
version = "0.5.7"
7891
+
version = "0.5.8"
7812
7892
dependencies = [
7813
7893
"async-trait",
7814
7894
"backon",
···
7833
7913
7834
7914
[[package]]
7835
7915
name = "tranquil-scopes"
7836
-
version = "0.5.7"
7916
+
version = "0.5.8"
7837
7917
dependencies = [
7838
7918
"axum",
7839
7919
"futures",
···
7849
7929
7850
7930
[[package]]
7851
7931
name = "tranquil-server"
7852
-
version = "0.5.7"
7932
+
version = "0.5.8"
7853
7933
dependencies = [
7854
7934
"axum",
7855
7935
"clap",
···
7870
7950
7871
7951
[[package]]
7872
7952
name = "tranquil-signal"
7873
-
version = "0.5.7"
7953
+
version = "0.5.8"
7874
7954
dependencies = [
7875
7955
"async-trait",
7876
7956
"chrono",
···
7893
7973
7894
7974
[[package]]
7895
7975
name = "tranquil-storage"
7896
-
version = "0.5.7"
7976
+
version = "0.5.8"
7897
7977
dependencies = [
7898
7978
"async-trait",
7899
7979
"aws-config",
···
7910
7990
7911
7991
[[package]]
7912
7992
name = "tranquil-store"
7913
-
version = "0.5.7"
7993
+
version = "0.5.8"
7914
7994
dependencies = [
7915
7995
"async-trait",
7916
7996
"bytes",
···
7959
8039
7960
8040
[[package]]
7961
8041
name = "tranquil-sync"
7962
-
version = "0.5.7"
8042
+
version = "0.5.8"
7963
8043
dependencies = [
7964
8044
"anyhow",
7965
8045
"axum",
···
7981
8061
7982
8062
[[package]]
7983
8063
name = "tranquil-types"
7984
-
version = "0.5.7"
8064
+
version = "0.5.8"
7985
8065
dependencies = [
7986
8066
"chrono",
7987
8067
"cid",
···
8496
8576
"base64urlsafedata",
8497
8577
"der-parser",
8498
8578
"hex",
8499
-
"nom",
8579
+
"nom 7.1.3",
8500
8580
"openssl",
8501
8581
"openssl-sys",
8502
8582
"rand 0.9.2",
···
9068
9148
"data-encoding",
9069
9149
"der-parser",
9070
9150
"lazy_static",
9071
-
"nom",
9151
+
"nom 7.1.3",
9072
9152
"oid-registry",
9073
9153
"rusticata-macros",
9074
9154
"thiserror 1.0.69",
+4
-1
Cargo.toml
+4
-1
Cargo.toml
···
26
26
]
27
27
28
28
[workspace.package]
29
-
version = "0.5.7"
29
+
version = "0.5.8"
30
30
edition = "2024"
31
31
license = "AGPL-3.0-or-later"
32
32
···
93
93
iroh-car = "0.5"
94
94
jacquard-common = { version = "0.9", features = ["crypto-k256"] }
95
95
jacquard-repo = "0.9"
96
+
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls-tls", "pool", "dkim", "tracing"] }
96
97
jsonwebtoken = { version = "10.2", features = ["rust_crypto"] }
97
98
k256 = { version = "0.13", features = ["ecdsa", "pem", "pkcs8"] }
98
99
metrics = "0.24"
···
105
106
rand = "0.8"
106
107
redis = { version = "1.0", features = ["tokio-comp", "connection-manager"] }
107
108
regex = "1"
109
+
rsa = "0.9"
110
+
secrecy = { version = "0.10", features = ["serde"] }
108
111
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "http2", "charset", "macos-system-configuration"] }
109
112
serde = { version = "1.0", features = ["derive"] }
110
113
serde_bytes = "0.11"
+11
crates/tranquil-comms/Cargo.toml
+11
crates/tranquil-comms/Cargo.toml
···
10
10
11
11
async-trait = { workspace = true }
12
12
base64 = { workspace = true }
13
+
ed25519-dalek = { workspace = true }
14
+
futures = { workspace = true }
15
+
hickory-resolver = { workspace = true }
16
+
lettre = { workspace = true }
17
+
rand = { workspace = true }
13
18
reqwest = { workspace = true }
19
+
rsa = { workspace = true }
20
+
secrecy = { workspace = true }
14
21
serde_json = { workspace = true }
15
22
sqlx = { workspace = true }
16
23
thiserror = { workspace = true }
···
18
25
tracing = { workspace = true }
19
26
tranquil-db-traits = { workspace = true }
20
27
uuid = { workspace = true }
28
+
29
+
[dev-dependencies]
30
+
chrono = { workspace = true }
31
+
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time", "io-util", "net"] }
+298
crates/tranquil-comms/src/email/types.rs
+298
crates/tranquil-comms/src/email/types.rs
···
1
+
use std::path::PathBuf;
2
+
3
+
#[derive(Debug, thiserror::Error)]
4
+
pub enum ParseError {
5
+
#[error("empty value")]
6
+
Empty,
7
+
#[error("invalid character {0:?}")]
8
+
InvalidChar(char),
9
+
#[error("zero {0}")]
10
+
Zero(&'static str),
11
+
#[error("invalid TLS mode {0:?}")]
12
+
InvalidTlsMode(String),
13
+
}
14
+
15
+
fn parse_token(
16
+
raw: &str,
17
+
lowercase: bool,
18
+
strip_trailing_dot: bool,
19
+
) -> Result<String, ParseError> {
20
+
let mut s = raw.trim();
21
+
if strip_trailing_dot {
22
+
s = s.trim_end_matches('.');
23
+
}
24
+
match s {
25
+
"" => Err(ParseError::Empty),
26
+
_ if s.chars().any(char::is_whitespace) => Err(ParseError::InvalidChar(' ')),
27
+
_ => Ok(match lowercase {
28
+
true => s.to_lowercase(),
29
+
false => s.to_string(),
30
+
}),
31
+
}
32
+
}
33
+
34
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35
+
pub struct SmtpHost(String);
36
+
37
+
impl SmtpHost {
38
+
pub fn parse(raw: &str) -> Result<Self, ParseError> {
39
+
parse_token(raw, true, false).map(Self)
40
+
}
41
+
42
+
pub fn as_str(&self) -> &str {
43
+
&self.0
44
+
}
45
+
}
46
+
47
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48
+
pub struct SmtpPort(u16);
49
+
50
+
impl SmtpPort {
51
+
pub fn parse(raw: u16) -> Result<Self, ParseError> {
52
+
match raw {
53
+
0 => Err(ParseError::Zero("smtp port")),
54
+
n => Ok(Self(n)),
55
+
}
56
+
}
57
+
58
+
pub fn as_u16(self) -> u16 {
59
+
self.0
60
+
}
61
+
}
62
+
63
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
64
+
pub struct HeloName(String);
65
+
66
+
impl HeloName {
67
+
pub fn parse(raw: &str) -> Result<Self, ParseError> {
68
+
parse_token(raw, false, false).map(Self)
69
+
}
70
+
71
+
pub fn as_str(&self) -> &str {
72
+
&self.0
73
+
}
74
+
75
+
pub fn into_inner(self) -> String {
76
+
self.0
77
+
}
78
+
}
79
+
80
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
81
+
pub struct EmailDomain(String);
82
+
83
+
impl EmailDomain {
84
+
pub fn parse(raw: &str) -> Result<Self, ParseError> {
85
+
parse_token(raw, true, true).map(Self)
86
+
}
87
+
88
+
pub fn as_str(&self) -> &str {
89
+
&self.0
90
+
}
91
+
92
+
pub fn into_inner(self) -> String {
93
+
self.0
94
+
}
95
+
}
96
+
97
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
98
+
pub struct MxHost(String);
99
+
100
+
impl MxHost {
101
+
pub fn parse(raw: &str) -> Result<Self, ParseError> {
102
+
parse_token(raw, true, true).map(Self)
103
+
}
104
+
105
+
pub fn as_str(&self) -> &str {
106
+
&self.0
107
+
}
108
+
}
109
+
110
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
111
+
pub struct MxPriority(u16);
112
+
113
+
impl MxPriority {
114
+
pub fn new(value: u16) -> Self {
115
+
Self(value)
116
+
}
117
+
118
+
pub fn as_u16(self) -> u16 {
119
+
self.0
120
+
}
121
+
}
122
+
123
+
#[derive(Debug, Clone)]
124
+
pub struct MxRecord {
125
+
pub priority: MxPriority,
126
+
pub host: MxHost,
127
+
}
128
+
129
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
130
+
pub struct DkimSelector(String);
131
+
132
+
impl DkimSelector {
133
+
pub fn parse(raw: &str) -> Result<Self, ParseError> {
134
+
let trimmed = raw.trim();
135
+
let valid = !trimmed.is_empty() && trimmed.split('.').all(valid_subdomain);
136
+
match valid {
137
+
true => Ok(Self(trimmed.to_string())),
138
+
false => Err(ParseError::InvalidChar('?')),
139
+
}
140
+
}
141
+
142
+
pub fn into_inner(self) -> String {
143
+
self.0
144
+
}
145
+
}
146
+
147
+
fn valid_subdomain(seg: &str) -> bool {
148
+
let starts_alnum = seg
149
+
.chars()
150
+
.next()
151
+
.is_some_and(|c| c.is_ascii_alphanumeric());
152
+
let ends_alnum = seg
153
+
.chars()
154
+
.next_back()
155
+
.is_some_and(|c| c.is_ascii_alphanumeric());
156
+
let body_ok = seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
157
+
starts_alnum && ends_alnum && body_ok
158
+
}
159
+
160
+
#[derive(Debug, Clone)]
161
+
pub struct DkimKeyPath(PathBuf);
162
+
163
+
impl DkimKeyPath {
164
+
pub fn parse(raw: &str) -> Result<Self, ParseError> {
165
+
let trimmed = raw.trim();
166
+
match trimmed.is_empty() {
167
+
true => Err(ParseError::Empty),
168
+
false => Ok(Self(PathBuf::from(trimmed))),
169
+
}
170
+
}
171
+
172
+
pub fn as_path(&self) -> &std::path::Path {
173
+
&self.0
174
+
}
175
+
}
176
+
177
+
#[derive(Debug, Clone, PartialEq, Eq)]
178
+
pub struct SmtpUsername(String);
179
+
180
+
impl SmtpUsername {
181
+
pub fn parse(raw: &str) -> Result<Self, ParseError> {
182
+
match raw.is_empty() {
183
+
true => Err(ParseError::Empty),
184
+
false => Ok(Self(raw.to_string())),
185
+
}
186
+
}
187
+
188
+
pub fn into_inner(self) -> String {
189
+
self.0
190
+
}
191
+
}
192
+
193
+
#[derive(Clone)]
194
+
pub struct SmtpPassword(secrecy::SecretString);
195
+
196
+
impl SmtpPassword {
197
+
pub fn parse(raw: &str) -> Result<Self, ParseError> {
198
+
match raw.is_empty() {
199
+
true => Err(ParseError::Empty),
200
+
false => Ok(Self(secrecy::SecretString::from(raw.to_string()))),
201
+
}
202
+
}
203
+
204
+
pub fn expose(&self) -> &str {
205
+
use secrecy::ExposeSecret;
206
+
self.0.expose_secret()
207
+
}
208
+
}
209
+
210
+
impl std::fmt::Debug for SmtpPassword {
211
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212
+
f.write_str("SmtpPassword(***)")
213
+
}
214
+
}
215
+
216
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217
+
pub enum TlsMode {
218
+
Implicit,
219
+
Starttls,
220
+
None,
221
+
}
222
+
223
+
impl TlsMode {
224
+
pub fn parse(raw: &str) -> Result<Self, ParseError> {
225
+
match raw.to_ascii_lowercase().as_str() {
226
+
"implicit" => Ok(Self::Implicit),
227
+
"starttls" => Ok(Self::Starttls),
228
+
"none" => Ok(Self::None),
229
+
other => Err(ParseError::InvalidTlsMode(other.to_string())),
230
+
}
231
+
}
232
+
}
233
+
234
+
#[cfg(test)]
235
+
mod tests {
236
+
use super::*;
237
+
238
+
#[test]
239
+
fn smtp_host_lowercases_and_trims() {
240
+
let h = SmtpHost::parse(" SMTP.NEL.PET ").unwrap();
241
+
assert_eq!(h.as_str(), "smtp.nel.pet");
242
+
}
243
+
244
+
#[test]
245
+
fn smtp_host_rejects_whitespace() {
246
+
assert!(SmtpHost::parse("a b").is_err());
247
+
}
248
+
249
+
#[test]
250
+
fn smtp_host_rejects_empty() {
251
+
assert!(SmtpHost::parse("").is_err());
252
+
assert!(SmtpHost::parse(" ").is_err());
253
+
}
254
+
255
+
#[test]
256
+
fn smtp_port_rejects_zero() {
257
+
assert!(SmtpPort::parse(0).is_err());
258
+
assert_eq!(SmtpPort::parse(587).unwrap().as_u16(), 587);
259
+
}
260
+
261
+
#[test]
262
+
fn email_domain_strips_trailing_dot() {
263
+
assert_eq!(
264
+
EmailDomain::parse("Nel.pet.").unwrap().as_str(),
265
+
"nel.pet"
266
+
);
267
+
}
268
+
269
+
#[test]
270
+
fn dkim_selector_validates() {
271
+
assert!(DkimSelector::parse("default").is_ok());
272
+
assert!(DkimSelector::parse("s1.nel.pet").is_ok());
273
+
assert!(DkimSelector::parse("s2024-q1").is_ok());
274
+
assert!(DkimSelector::parse("mailo-2024.nel.pet").is_ok());
275
+
assert!(DkimSelector::parse("a-b").is_ok());
276
+
assert!(DkimSelector::parse("").is_err());
277
+
assert!(DkimSelector::parse("a..b").is_err());
278
+
assert!(DkimSelector::parse("-leading").is_err());
279
+
assert!(DkimSelector::parse("trailing-").is_err());
280
+
assert!(DkimSelector::parse("s_under").is_err());
281
+
}
282
+
283
+
#[test]
284
+
fn tls_mode_parses_known_modes() {
285
+
assert_eq!(TlsMode::parse("STARTTLS").unwrap(), TlsMode::Starttls);
286
+
assert_eq!(TlsMode::parse("implicit").unwrap(), TlsMode::Implicit);
287
+
assert_eq!(TlsMode::parse("none").unwrap(), TlsMode::None);
288
+
assert!(TlsMode::parse("garbage").is_err());
289
+
}
290
+
291
+
#[test]
292
+
fn smtp_password_redacts_in_debug() {
293
+
let p = SmtpPassword::parse("hunter2").unwrap();
294
+
let dbg = format!("{:?}", p);
295
+
assert_eq!(dbg, "SmtpPassword(***)");
296
+
assert!(!dbg.contains("hunter2"));
297
+
}
298
+
}
+171
-3
crates/tranquil-config/src/lib.rs
+171
-3
crates/tranquil-config/src/lib.rs
···
210
210
}
211
211
}
212
212
213
+
// -- email smarthost --------------------------------------------------
214
+
match self.email.smarthost.tls.to_ascii_lowercase().as_str() {
215
+
"implicit" | "starttls" => {}
216
+
"none" => {
217
+
if self.email.smarthost.password.is_some() {
218
+
errors.push(
219
+
"email.smarthost.tls = \"none\" with email.smarthost.password set \
220
+
would transmit credentials in plaintext; use \"starttls\" or \"implicit\""
221
+
.to_string(),
222
+
);
223
+
}
224
+
}
225
+
other => errors.push(format!(
226
+
"email.smarthost.tls must be \"implicit\", \"starttls\", or \"none\", got \"{other}\""
227
+
)),
228
+
}
229
+
230
+
let smarthost_host_set = self
231
+
.email
232
+
.smarthost
233
+
.host
234
+
.as_deref()
235
+
.is_some_and(|h| !h.is_empty());
236
+
let username_set = self.email.smarthost.username.is_some();
237
+
let password_set = self.email.smarthost.password.is_some();
238
+
if !smarthost_host_set && (username_set || password_set) {
239
+
errors.push(
240
+
"email.smarthost.username or email.smarthost.password is set but \
241
+
email.smarthost.host is empty; credentials would be silently ignored"
242
+
.to_string(),
243
+
);
244
+
}
245
+
if smarthost_host_set && username_set != password_set {
246
+
errors.push(
247
+
"email.smarthost.username and email.smarthost.password must both be set or \
248
+
both unset; otherwise authentication would silently degrade to anonymous"
249
+
.to_string(),
250
+
);
251
+
}
252
+
253
+
if self.email.smarthost.command_timeout_secs == 0 {
254
+
errors.push("email.smarthost.command_timeout_secs must be at least 1".to_string());
255
+
}
256
+
if self.email.smarthost.total_timeout_secs == 0 {
257
+
errors.push("email.smarthost.total_timeout_secs must be at least 1".to_string());
258
+
}
259
+
if self.email.smarthost.pool_size == 0 {
260
+
errors.push("email.smarthost.pool_size must be at least 1".to_string());
261
+
}
262
+
263
+
if self.email.direct_mx.max_concurrent_sends == 0 {
264
+
errors.push("email.direct_mx.max_concurrent_sends must be at least 1".to_string());
265
+
}
266
+
if self.email.direct_mx.command_timeout_secs == 0 {
267
+
errors.push("email.direct_mx.command_timeout_secs must be at least 1".to_string());
268
+
}
269
+
if self.email.direct_mx.total_timeout_secs == 0 {
270
+
errors.push("email.direct_mx.total_timeout_secs must be at least 1".to_string());
271
+
}
272
+
273
+
let dkim_set = self.email.dkim.selector.is_some()
274
+
|| self.email.dkim.domain.is_some()
275
+
|| self.email.dkim.private_key_path.is_some();
276
+
if dkim_set {
277
+
if self.email.dkim.selector.is_none() {
278
+
errors
279
+
.push("email.dkim.selector is required when any DKIM field is set".to_string());
280
+
}
281
+
if self.email.dkim.domain.is_none() {
282
+
errors.push("email.dkim.domain is required when any DKIM field is set".to_string());
283
+
}
284
+
if self.email.dkim.private_key_path.is_none() {
285
+
errors.push(
286
+
"email.dkim.private_key_path is required when any DKIM field is set"
287
+
.to_string(),
288
+
);
289
+
}
290
+
}
291
+
213
292
// -- telegram ---------------------------------------------------------
214
293
if self.telegram.bot_token.is_some() && self.telegram.webhook_secret.is_none() {
215
294
errors.push(
···
754
833
#[config(env = "MAIL_FROM_NAME", default = "Tranquil PDS")]
755
834
pub from_name: String,
756
835
757
-
/// Path to the `sendmail` binary.
758
-
#[config(env = "SENDMAIL_PATH", default = "/usr/sbin/sendmail")]
759
-
pub sendmail_path: String,
836
+
/// HELO/EHLO name announced to remote SMTP servers. Applies to both
837
+
/// smarthost and direct-MX modes. Defaults to the server hostname.
838
+
#[config(env = "MAIL_HELO_NAME")]
839
+
pub helo_name: Option<String>,
840
+
841
+
#[config(nested)]
842
+
pub smarthost: SmarthostConfig,
843
+
844
+
#[config(nested)]
845
+
pub direct_mx: DirectMxConfig,
846
+
847
+
#[config(nested)]
848
+
pub dkim: DkimConfig,
849
+
}
850
+
851
+
#[derive(Debug, Config)]
852
+
pub struct SmarthostConfig {
853
+
/// SMTP relay host. When set, mail is delivered through this host
854
+
/// instead of resolving recipient MX records directly.
855
+
#[config(env = "MAIL_SMARTHOST_HOST")]
856
+
pub host: Option<String>,
857
+
858
+
/// SMTP relay port.
859
+
#[config(env = "MAIL_SMARTHOST_PORT", default = 587)]
860
+
pub port: u16,
861
+
862
+
/// SMTP authentication username.
863
+
#[config(env = "MAIL_SMARTHOST_USERNAME")]
864
+
pub username: Option<String>,
865
+
866
+
/// SMTP authentication password.
867
+
#[config(env = "MAIL_SMARTHOST_PASSWORD")]
868
+
pub password: Option<String>,
869
+
870
+
/// TLS mode. Valid values: "implicit", "starttls", "none". Setting "none"
871
+
/// alongside a password is rejected at startup to prevent transmitting
872
+
/// credentials in plaintext.
873
+
#[config(env = "MAIL_SMARTHOST_TLS", default = "starttls")]
874
+
pub tls: String,
875
+
876
+
/// Max size of the connection pool.
877
+
#[config(env = "MAIL_SMARTHOST_POOL_SIZE", default = 4)]
878
+
pub pool_size: u32,
879
+
880
+
/// Per-command SMTP timeout in seconds. Bounds the security handshake.
881
+
#[config(env = "MAIL_SMARTHOST_COMMAND_TIMEOUT_SECS", default = 30)]
882
+
pub command_timeout_secs: u64,
883
+
884
+
/// Total per-message timeout in seconds. Wraps the entire send so a
885
+
/// stuck relay cannot stall the comms queue.
886
+
#[config(env = "MAIL_SMARTHOST_TOTAL_TIMEOUT_SECS", default = 60)]
887
+
pub total_timeout_secs: u64,
888
+
}
889
+
890
+
#[derive(Debug, Config)]
891
+
pub struct DirectMxConfig {
892
+
/// Per-command SMTP timeout in seconds.
893
+
#[config(env = "MAIL_COMMAND_TIMEOUT_SECS", default = 30)]
894
+
pub command_timeout_secs: u64,
895
+
896
+
/// Total per-message timeout across all MX attempts in seconds.
897
+
#[config(env = "MAIL_TOTAL_TIMEOUT_SECS", default = 60)]
898
+
pub total_timeout_secs: u64,
899
+
900
+
/// Max number of concurrent direct-MX sends. Limits the load placed
901
+
/// on any single recipient MX during a backlog drain.
902
+
#[config(env = "MAIL_MAX_CONCURRENT_SENDS", default = 8)]
903
+
pub max_concurrent_sends: usize,
904
+
905
+
/// Require STARTTLS on every MX hop. When false, TLS is
906
+
/// attempted opportunistically and the session falls back to plaintext
907
+
/// if the remote does not advertise STARTTLS. Set true to refuse
908
+
/// plaintext delivery, at the cost of failing sends to MX hosts that
909
+
/// do not support TLS.
910
+
#[config(env = "MAIL_REQUIRE_TLS", default = false)]
911
+
pub require_tls: bool,
912
+
}
913
+
914
+
#[derive(Debug, Config)]
915
+
pub struct DkimConfig {
916
+
/// DKIM selector. When unset, outgoing mail is not signed.
917
+
#[config(env = "MAIL_DKIM_SELECTOR")]
918
+
pub selector: Option<String>,
919
+
920
+
/// DKIM signing domain.
921
+
#[config(env = "MAIL_DKIM_DOMAIN")]
922
+
pub domain: Option<String>,
923
+
924
+
/// Path to the DKIM private key in PEM format. Supports RSA and
925
+
/// Ed25519 keys.
926
+
#[config(env = "MAIL_DKIM_KEY_PATH")]
927
+
pub private_key_path: Option<String>,
760
928
}
761
929
762
930
#[derive(Debug, Config)]
+109
-4
example.toml
+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>