Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(tranquil-comms): message construction and mx resolution #7

open opened by oyster.cafe targeting main from feat/inline-emailing
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mkuoecys4x22
+401
Diff #3
+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
··· 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
sign up or login to add to the discussion
1 commit
expand
feat(tranquil-comms): message construction and mx resolution
merge conflicts detected
expand
  • .config/nextest.toml:68
  • Cargo.lock:9
  • Cargo.toml:26
  • crates/tranquil-comms/Cargo.toml:10
  • crates/tranquil-config/src/lib.rs:5
  • example.toml:373
expand 0 comments
1 commit
expand
feat(tranquil-comms): message construction and mx resolution
expand 0 comments
1 commit
expand
feat(tranquil-comms): message construction and mx resolution
expand 0 comments
1 commit
expand
feat(tranquil-comms): message construction and mx resolution
expand 0 comments