Lewis: May this revision serve well! lu5a@proton.me
+401
Diff
round #3
+151
crates/tranquil-comms/src/email/message.rs
+151
crates/tranquil-comms/src/email/message.rs
···
1
+
use lettre::Message;
2
+
use lettre::message::Mailbox;
3
+
use lettre::message::header::ContentType;
4
+
use uuid::Uuid;
5
+
6
+
use super::types::EmailDomain;
7
+
use crate::sender::SendError;
8
+
use crate::types::QueuedComms;
9
+
10
+
pub(super) fn build(from: &Mailbox, qc: &QueuedComms) -> Result<Message, SendError> {
11
+
let to: Mailbox = qc
12
+
.recipient
13
+
.parse()
14
+
.map_err(|e: lettre::address::AddressError| SendError::InvalidRecipient(e.to_string()))?;
15
+
let subject = qc.subject.as_deref().unwrap_or("Notification");
16
+
let message_id = format!("<{}@{}>", Uuid::new_v4(), from.email.domain());
17
+
Message::builder()
18
+
.from(from.clone())
19
+
.to(to)
20
+
.subject(subject)
21
+
.message_id(Some(message_id))
22
+
.header(ContentType::TEXT_PLAIN)
23
+
.body(qc.body.clone())
24
+
.map_err(|e| SendError::MessageBuild(e.to_string()))
25
+
}
26
+
27
+
pub(super) fn recipient_domain(message: &Message) -> Result<EmailDomain, SendError> {
28
+
let envelope = message.envelope();
29
+
let first = envelope
30
+
.to()
31
+
.first()
32
+
.ok_or_else(|| SendError::MessageBuild("envelope has no recipients".to_string()))?;
33
+
EmailDomain::parse(first.domain())
34
+
.map_err(|e| SendError::InvalidRecipient(format!("invalid recipient domain: {e}")))
35
+
}
36
+
37
+
#[cfg(test)]
38
+
mod tests {
39
+
use super::*;
40
+
use crate::types::{CommsChannel, CommsStatus, CommsType};
41
+
use chrono::Utc;
42
+
use uuid::Uuid;
43
+
44
+
fn from_mailbox() -> Mailbox {
45
+
"Test Sender <noreply@nel.pet>".parse().unwrap()
46
+
}
47
+
48
+
fn fixture(recipient: &str, subject: Option<&str>, body: &str) -> QueuedComms {
49
+
QueuedComms {
50
+
id: Uuid::new_v4(),
51
+
user_id: None,
52
+
channel: CommsChannel::Email,
53
+
comms_type: CommsType::Welcome,
54
+
status: CommsStatus::Pending,
55
+
recipient: recipient.to_string(),
56
+
subject: subject.map(String::from),
57
+
body: body.to_string(),
58
+
metadata: None,
59
+
attempts: 0,
60
+
max_attempts: 3,
61
+
last_error: None,
62
+
created_at: Utc::now(),
63
+
updated_at: Utc::now(),
64
+
scheduled_for: Utc::now(),
65
+
processed_at: None,
66
+
}
67
+
}
68
+
69
+
#[test]
70
+
fn build_basic_message() {
71
+
let msg = build(
72
+
&from_mailbox(),
73
+
&fixture("user@nel.pet", Some("Welcome"), "Hello world."),
74
+
)
75
+
.unwrap();
76
+
let raw = String::from_utf8(msg.formatted()).unwrap();
77
+
let lower = raw.to_lowercase();
78
+
assert!(raw.contains("From: \"Test Sender\" <noreply@nel.pet>"));
79
+
assert!(raw.contains("To: user@nel.pet"));
80
+
assert!(raw.contains("Subject: Welcome"));
81
+
assert!(lower.contains("content-type: text/plain"));
82
+
assert!(raw.contains("Hello world."));
83
+
}
84
+
85
+
#[test]
86
+
fn utf8_subject_is_encoded() {
87
+
let msg = build(
88
+
&from_mailbox(),
89
+
&fixture("user@nel.pet", Some("h茅llo w枚rld"), "Body"),
90
+
)
91
+
.unwrap();
92
+
let raw = String::from_utf8(msg.formatted()).unwrap();
93
+
assert!(raw.contains("=?utf-8?"));
94
+
assert!(!raw.contains("h茅llo"));
95
+
}
96
+
97
+
#[test]
98
+
fn header_injection_rejected() {
99
+
let result = build(
100
+
&from_mailbox(),
101
+
&fixture("x@nel.pet\r\nBcc: evil@x", Some("s"), "b"),
102
+
);
103
+
assert!(matches!(result, Err(SendError::InvalidRecipient(_))));
104
+
}
105
+
106
+
#[test]
107
+
fn subject_crlf_does_not_inject_headers() {
108
+
let msg = build(
109
+
&from_mailbox(),
110
+
&fixture("user@nel.pet", Some("hi\r\nBcc: evil@nel.pet"), "body"),
111
+
)
112
+
.expect("subject CRLF should be encoded, not rejected");
113
+
let raw = String::from_utf8(msg.formatted()).unwrap();
114
+
assert!(
115
+
!raw.contains("Bcc:"),
116
+
"CRLF in subject must not produce a Bcc header: {raw}"
117
+
);
118
+
assert!(
119
+
raw.contains("Subject: ="),
120
+
"subject with non-printable chars should be RFC 2047 encoded: {raw}"
121
+
);
122
+
}
123
+
124
+
#[test]
125
+
fn message_id_uses_from_domain() {
126
+
let msg = build(&from_mailbox(), &fixture("user@nel.pet", Some("s"), "b")).unwrap();
127
+
let raw = String::from_utf8(msg.formatted()).unwrap();
128
+
let line = raw
129
+
.lines()
130
+
.find(|l| l.starts_with("Message-ID:") || l.starts_with("Message-Id:"))
131
+
.expect("message-id header present");
132
+
assert!(
133
+
line.contains("@nel.pet>"),
134
+
"message-id should use From domain: {line}"
135
+
);
136
+
}
137
+
138
+
#[test]
139
+
fn missing_subject_uses_default() {
140
+
let msg = build(&from_mailbox(), &fixture("user@nel.pet", None, "Body")).unwrap();
141
+
let raw = String::from_utf8(msg.formatted()).unwrap();
142
+
assert!(raw.contains("Subject: Notification"));
143
+
}
144
+
145
+
#[test]
146
+
fn recipient_domain_extracted() {
147
+
let msg = build(&from_mailbox(), &fixture("user@Nel.PET", Some("s"), "b")).unwrap();
148
+
let d = recipient_domain(&msg).unwrap();
149
+
assert_eq!(d.as_str(), "nel.pet");
150
+
}
151
+
}
+250
crates/tranquil-comms/src/email/mx.rs
+250
crates/tranquil-comms/src/email/mx.rs
···
1
+
use hickory_resolver::TokioAsyncResolver;
2
+
use hickory_resolver::error::{ResolveError, ResolveErrorKind};
3
+
use hickory_resolver::proto::op::ResponseCode;
4
+
use rand::seq::SliceRandom;
5
+
6
+
use super::types::{EmailDomain, MxHost, MxPriority, MxRecord};
7
+
use crate::sender::SendError;
8
+
9
+
pub async fn resolve(
10
+
resolver: &TokioAsyncResolver,
11
+
domain: &EmailDomain,
12
+
) -> Result<Vec<MxRecord>, SendError> {
13
+
match resolver.mx_lookup(domain.as_str()).await {
14
+
Ok(lookup) => interpret_lookup(
15
+
lookup
16
+
.iter()
17
+
.map(|mx| (mx.preference(), mx.exchange().clone())),
18
+
domain,
19
+
),
20
+
Err(e) => classify_lookup_error(e, domain),
21
+
}
22
+
}
23
+
24
+
fn interpret_lookup(
25
+
items: impl IntoIterator<Item = (u16, hickory_resolver::Name)>,
26
+
domain: &EmailDomain,
27
+
) -> Result<Vec<MxRecord>, SendError> {
28
+
let entries: Vec<_> = items.into_iter().collect();
29
+
match entries.iter().any(|(_, name)| name.is_root()) {
30
+
true => Err(SendError::DnsPermanent(format!(
31
+
"null MX record at {}: domain refuses mail",
32
+
domain.as_str()
33
+
))),
34
+
false => {
35
+
let records: Vec<MxRecord> = entries
36
+
.into_iter()
37
+
.filter_map(|(prio, name)| {
38
+
MxHost::parse(&name.to_utf8()).ok().map(|host| MxRecord {
39
+
priority: MxPriority::new(prio),
40
+
host,
41
+
})
42
+
})
43
+
.collect();
44
+
match records.is_empty() {
45
+
true => implicit_mx(domain),
46
+
false => Ok(prioritize(records)),
47
+
}
48
+
}
49
+
}
50
+
}
51
+
52
+
fn prioritize(mut records: Vec<MxRecord>) -> Vec<MxRecord> {
53
+
records.shuffle(&mut rand::thread_rng());
54
+
records.sort_by_key(|r| r.priority);
55
+
records
56
+
}
57
+
58
+
fn classify_lookup_error(
59
+
e: ResolveError,
60
+
domain: &EmailDomain,
61
+
) -> Result<Vec<MxRecord>, SendError> {
62
+
match e.kind() {
63
+
ResolveErrorKind::NoRecordsFound { response_code, .. } => match *response_code {
64
+
ResponseCode::NoError => implicit_mx(domain),
65
+
ResponseCode::NXDomain => Err(SendError::DnsPermanent(format!(
66
+
"domain {} does not exist",
67
+
domain.as_str()
68
+
))),
69
+
other => Err(SendError::DnsTransient(format!(
70
+
"MX lookup for {} failed with {other}",
71
+
domain.as_str()
72
+
))),
73
+
},
74
+
_ => Err(SendError::DnsTransient(e.to_string())),
75
+
}
76
+
}
77
+
78
+
fn implicit_mx(domain: &EmailDomain) -> Result<Vec<MxRecord>, SendError> {
79
+
MxHost::parse(domain.as_str())
80
+
.map(|host| {
81
+
vec![MxRecord {
82
+
priority: MxPriority::new(0),
83
+
host,
84
+
}]
85
+
})
86
+
.map_err(|e| SendError::DnsPermanent(format!("invalid recipient domain: {e}")))
87
+
}
88
+
89
+
#[cfg(test)]
90
+
mod tests {
91
+
use super::*;
92
+
93
+
fn record(prio: u16, host: &str) -> MxRecord {
94
+
MxRecord {
95
+
priority: MxPriority::new(prio),
96
+
host: MxHost::parse(host).unwrap(),
97
+
}
98
+
}
99
+
100
+
#[test]
101
+
fn prioritize_sorts_by_priority_ascending() {
102
+
let result = prioritize(vec![
103
+
record(20, "mx2.nel.pet"),
104
+
record(10, "mx1.nel.pet"),
105
+
record(10, "mx1b.nel.pet"),
106
+
]);
107
+
assert_eq!(result[0].priority.as_u16(), 10);
108
+
assert_eq!(result[1].priority.as_u16(), 10);
109
+
assert_eq!(result[2].priority.as_u16(), 20);
110
+
}
111
+
112
+
#[test]
113
+
fn prioritize_randomizes_equal_priority_order() {
114
+
let attempts: Vec<Vec<String>> = (0..200)
115
+
.map(|_| {
116
+
prioritize(vec![
117
+
record(10, "a.nel.pet"),
118
+
record(10, "b.nel.pet"),
119
+
record(10, "c.nel.pet"),
120
+
record(10, "d.nel.pet"),
121
+
])
122
+
.into_iter()
123
+
.map(|r| r.host.as_str().to_string())
124
+
.collect()
125
+
})
126
+
.collect();
127
+
let distinct: std::collections::HashSet<_> = attempts.iter().cloned().collect();
128
+
assert!(
129
+
distinct.len() > 1,
130
+
"equal-priority MX order should vary across calls; got only {}",
131
+
distinct.len()
132
+
);
133
+
}
134
+
135
+
#[test]
136
+
fn implicit_mx_uses_domain_as_host() {
137
+
let d = EmailDomain::parse("nel.pet").unwrap();
138
+
let result = implicit_mx(&d).unwrap();
139
+
assert_eq!(result.len(), 1);
140
+
assert_eq!(result[0].priority.as_u16(), 0);
141
+
assert_eq!(result[0].host.as_str(), "nel.pet");
142
+
}
143
+
144
+
#[test]
145
+
fn no_error_response_yields_implicit_mx() {
146
+
let d = EmailDomain::parse("nel.pet").unwrap();
147
+
let err = ResolveError::from(ResolveErrorKind::NoRecordsFound {
148
+
query: Box::new(hickory_resolver::proto::op::Query::default()),
149
+
soa: None,
150
+
negative_ttl: None,
151
+
response_code: ResponseCode::NoError,
152
+
trusted: false,
153
+
});
154
+
let result = classify_lookup_error(err, &d).unwrap();
155
+
assert_eq!(result.len(), 1);
156
+
assert_eq!(result[0].host.as_str(), "nel.pet");
157
+
}
158
+
159
+
#[test]
160
+
fn nxdomain_response_is_permanent() {
161
+
let d = EmailDomain::parse("does-not-exist.invalid").unwrap();
162
+
let err = ResolveError::from(ResolveErrorKind::NoRecordsFound {
163
+
query: Box::new(hickory_resolver::proto::op::Query::default()),
164
+
soa: None,
165
+
negative_ttl: None,
166
+
response_code: ResponseCode::NXDomain,
167
+
trusted: true,
168
+
});
169
+
match classify_lookup_error(err, &d) {
170
+
Err(SendError::DnsPermanent(_)) => {}
171
+
other => panic!("expected DnsPermanent, got {other:?}"),
172
+
}
173
+
}
174
+
175
+
#[test]
176
+
fn servfail_response_is_transient() {
177
+
let d = EmailDomain::parse("nel.pet").unwrap();
178
+
let err = ResolveError::from(ResolveErrorKind::NoRecordsFound {
179
+
query: Box::new(hickory_resolver::proto::op::Query::default()),
180
+
soa: None,
181
+
negative_ttl: None,
182
+
response_code: ResponseCode::ServFail,
183
+
trusted: false,
184
+
});
185
+
match classify_lookup_error(err, &d) {
186
+
Err(SendError::DnsTransient(_)) => {}
187
+
other => panic!("expected DnsTransient, got {other:?}"),
188
+
}
189
+
}
190
+
191
+
#[test]
192
+
fn timeout_is_transient() {
193
+
let d = EmailDomain::parse("nel.pet").unwrap();
194
+
let err = ResolveError::from(ResolveErrorKind::Timeout);
195
+
match classify_lookup_error(err, &d) {
196
+
Err(SendError::DnsTransient(_)) => {}
197
+
other => panic!("expected DnsTransient, got {other:?}"),
198
+
}
199
+
}
200
+
201
+
#[test]
202
+
fn message_variant_is_transient() {
203
+
let d = EmailDomain::parse("nel.pet").unwrap();
204
+
let err = ResolveError::from(ResolveErrorKind::Message("transient resolver glitch"));
205
+
match classify_lookup_error(err, &d) {
206
+
Err(SendError::DnsTransient(_)) => {}
207
+
other => panic!("expected DnsTransient default, got {other:?}"),
208
+
}
209
+
}
210
+
211
+
#[test]
212
+
fn null_mx_is_permanent() {
213
+
let d = EmailDomain::parse("nomail.nel.pet").unwrap();
214
+
let result = interpret_lookup(vec![(0, hickory_resolver::Name::root())], &d);
215
+
match result {
216
+
Err(SendError::DnsPermanent(msg)) => {
217
+
assert!(msg.contains("null MX"), "msg: {msg}")
218
+
}
219
+
other => panic!("expected DnsPermanent, got {other:?}"),
220
+
}
221
+
}
222
+
223
+
#[test]
224
+
fn null_mx_alongside_real_records_still_permanent() {
225
+
let d = EmailDomain::parse("mixed.nel.pet").unwrap();
226
+
let real = hickory_resolver::Name::from_ascii("mx1.nel.pet.").unwrap();
227
+
let result = interpret_lookup(vec![(10, real), (0, hickory_resolver::Name::root())], &d);
228
+
assert!(matches!(result, Err(SendError::DnsPermanent(_))));
229
+
}
230
+
231
+
#[test]
232
+
fn empty_lookup_uses_implicit_mx() {
233
+
let d = EmailDomain::parse("nel.pet").unwrap();
234
+
let result = interpret_lookup(Vec::<(u16, hickory_resolver::Name)>::new(), &d).unwrap();
235
+
assert_eq!(result.len(), 1);
236
+
assert_eq!(result[0].host.as_str(), "nel.pet");
237
+
}
238
+
239
+
#[test]
240
+
fn valid_records_pass_through_with_priority_sort() {
241
+
let d = EmailDomain::parse("nel.pet").unwrap();
242
+
let mx1 = hickory_resolver::Name::from_ascii("mx1.nel.pet.").unwrap();
243
+
let mx2 = hickory_resolver::Name::from_ascii("mx2.nel.pet.").unwrap();
244
+
let result = interpret_lookup(vec![(20, mx2), (10, mx1)], &d).unwrap();
245
+
assert_eq!(result.len(), 2);
246
+
assert_eq!(result[0].priority.as_u16(), 10);
247
+
assert_eq!(result[0].host.as_str(), "mx1.nel.pet");
248
+
assert_eq!(result[1].priority.as_u16(), 20);
249
+
}
250
+
}
History
4 rounds
0 comments
oyster.cafe
submitted
#3
1 commit
expand
collapse
feat(tranquil-comms): message construction and mx resolution
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): message construction and mx resolution
Lewis: May this revision serve well! <lu5a@proton.me>
expand 0 comments
oyster.cafe
submitted
#1
1 commit
expand
collapse
feat(tranquil-comms): message construction and mx resolution
Lewis: May this revision serve well! <lu5a@proton.me>
expand 0 comments
oyster.cafe
submitted
#0
1 commit
expand
collapse
feat(tranquil-comms): message construction and mx resolution
Lewis: May this revision serve well! <lu5a@proton.me>