diff --git a/crates/tranquil-comms/src/email/message.rs b/crates/tranquil-comms/src/email/message.rs new file mode 100644 index 0000000..7d5df8f --- /dev/null +++ b/crates/tranquil-comms/src/email/message.rs @@ -0,0 +1,151 @@ +use lettre::Message; +use lettre::message::Mailbox; +use lettre::message::header::ContentType; +use uuid::Uuid; + +use super::types::EmailDomain; +use crate::sender::SendError; +use crate::types::QueuedComms; + +pub(super) fn build(from: &Mailbox, qc: &QueuedComms) -> Result { + let to: Mailbox = qc + .recipient + .parse() + .map_err(|e: lettre::address::AddressError| SendError::InvalidRecipient(e.to_string()))?; + let subject = qc.subject.as_deref().unwrap_or("Notification"); + let message_id = format!("<{}@{}>", Uuid::new_v4(), from.email.domain()); + Message::builder() + .from(from.clone()) + .to(to) + .subject(subject) + .message_id(Some(message_id)) + .header(ContentType::TEXT_PLAIN) + .body(qc.body.clone()) + .map_err(|e| SendError::MessageBuild(e.to_string())) +} + +pub(super) fn recipient_domain(message: &Message) -> Result { + let envelope = message.envelope(); + let first = envelope + .to() + .first() + .ok_or_else(|| SendError::MessageBuild("envelope has no recipients".to_string()))?; + EmailDomain::parse(first.domain()) + .map_err(|e| SendError::InvalidRecipient(format!("invalid recipient domain: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{CommsChannel, CommsStatus, CommsType}; + use chrono::Utc; + use uuid::Uuid; + + fn from_mailbox() -> Mailbox { + "Test Sender ".parse().unwrap() + } + + fn fixture(recipient: &str, subject: Option<&str>, body: &str) -> QueuedComms { + QueuedComms { + id: Uuid::new_v4(), + user_id: None, + channel: CommsChannel::Email, + comms_type: CommsType::Welcome, + status: CommsStatus::Pending, + recipient: recipient.to_string(), + subject: subject.map(String::from), + body: body.to_string(), + metadata: None, + attempts: 0, + max_attempts: 3, + last_error: None, + created_at: Utc::now(), + updated_at: Utc::now(), + scheduled_for: Utc::now(), + processed_at: None, + } + } + + #[test] + fn build_basic_message() { + let msg = build( + &from_mailbox(), + &fixture("user@nel.pet", Some("Welcome"), "Hello world."), + ) + .unwrap(); + let raw = String::from_utf8(msg.formatted()).unwrap(); + let lower = raw.to_lowercase(); + assert!(raw.contains("From: \"Test Sender\" ")); + assert!(raw.contains("To: user@nel.pet")); + assert!(raw.contains("Subject: Welcome")); + assert!(lower.contains("content-type: text/plain")); + assert!(raw.contains("Hello world.")); + } + + #[test] + fn utf8_subject_is_encoded() { + let msg = build( + &from_mailbox(), + &fixture("user@nel.pet", Some("héllo wörld"), "Body"), + ) + .unwrap(); + let raw = String::from_utf8(msg.formatted()).unwrap(); + assert!(raw.contains("=?utf-8?")); + assert!(!raw.contains("héllo")); + } + + #[test] + fn header_injection_rejected() { + let result = build( + &from_mailbox(), + &fixture("x@nel.pet\r\nBcc: evil@x", Some("s"), "b"), + ); + assert!(matches!(result, Err(SendError::InvalidRecipient(_)))); + } + + #[test] + fn subject_crlf_does_not_inject_headers() { + let msg = build( + &from_mailbox(), + &fixture("user@nel.pet", Some("hi\r\nBcc: evil@nel.pet"), "body"), + ) + .expect("subject CRLF should be encoded, not rejected"); + let raw = String::from_utf8(msg.formatted()).unwrap(); + assert!( + !raw.contains("Bcc:"), + "CRLF in subject must not produce a Bcc header: {raw}" + ); + assert!( + raw.contains("Subject: ="), + "subject with non-printable chars should be RFC 2047 encoded: {raw}" + ); + } + + #[test] + fn message_id_uses_from_domain() { + let msg = build(&from_mailbox(), &fixture("user@nel.pet", Some("s"), "b")).unwrap(); + let raw = String::from_utf8(msg.formatted()).unwrap(); + let line = raw + .lines() + .find(|l| l.starts_with("Message-ID:") || l.starts_with("Message-Id:")) + .expect("message-id header present"); + assert!( + line.contains("@nel.pet>"), + "message-id should use From domain: {line}" + ); + } + + #[test] + fn missing_subject_uses_default() { + let msg = build(&from_mailbox(), &fixture("user@nel.pet", None, "Body")).unwrap(); + let raw = String::from_utf8(msg.formatted()).unwrap(); + assert!(raw.contains("Subject: Notification")); + } + + #[test] + fn recipient_domain_extracted() { + let msg = build(&from_mailbox(), &fixture("user@Nel.PET", Some("s"), "b")).unwrap(); + let d = recipient_domain(&msg).unwrap(); + assert_eq!(d.as_str(), "nel.pet"); + } +} diff --git a/crates/tranquil-comms/src/email/mx.rs b/crates/tranquil-comms/src/email/mx.rs new file mode 100644 index 0000000..b04f192 --- /dev/null +++ b/crates/tranquil-comms/src/email/mx.rs @@ -0,0 +1,250 @@ +use hickory_resolver::TokioAsyncResolver; +use hickory_resolver::error::{ResolveError, ResolveErrorKind}; +use hickory_resolver::proto::op::ResponseCode; +use rand::seq::SliceRandom; + +use super::types::{EmailDomain, MxHost, MxPriority, MxRecord}; +use crate::sender::SendError; + +pub async fn resolve( + resolver: &TokioAsyncResolver, + domain: &EmailDomain, +) -> Result, SendError> { + match resolver.mx_lookup(domain.as_str()).await { + Ok(lookup) => interpret_lookup( + lookup + .iter() + .map(|mx| (mx.preference(), mx.exchange().clone())), + domain, + ), + Err(e) => classify_lookup_error(e, domain), + } +} + +fn interpret_lookup( + items: impl IntoIterator, + domain: &EmailDomain, +) -> Result, SendError> { + let entries: Vec<_> = items.into_iter().collect(); + match entries.iter().any(|(_, name)| name.is_root()) { + true => Err(SendError::DnsPermanent(format!( + "null MX record at {}: domain refuses mail", + domain.as_str() + ))), + false => { + let records: Vec = entries + .into_iter() + .filter_map(|(prio, name)| { + MxHost::parse(&name.to_utf8()).ok().map(|host| MxRecord { + priority: MxPriority::new(prio), + host, + }) + }) + .collect(); + match records.is_empty() { + true => implicit_mx(domain), + false => Ok(prioritize(records)), + } + } + } +} + +fn prioritize(mut records: Vec) -> Vec { + records.shuffle(&mut rand::thread_rng()); + records.sort_by_key(|r| r.priority); + records +} + +fn classify_lookup_error( + e: ResolveError, + domain: &EmailDomain, +) -> Result, SendError> { + match e.kind() { + ResolveErrorKind::NoRecordsFound { response_code, .. } => match *response_code { + ResponseCode::NoError => implicit_mx(domain), + ResponseCode::NXDomain => Err(SendError::DnsPermanent(format!( + "domain {} does not exist", + domain.as_str() + ))), + other => Err(SendError::DnsTransient(format!( + "MX lookup for {} failed with {other}", + domain.as_str() + ))), + }, + _ => Err(SendError::DnsTransient(e.to_string())), + } +} + +fn implicit_mx(domain: &EmailDomain) -> Result, SendError> { + MxHost::parse(domain.as_str()) + .map(|host| { + vec![MxRecord { + priority: MxPriority::new(0), + host, + }] + }) + .map_err(|e| SendError::DnsPermanent(format!("invalid recipient domain: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(prio: u16, host: &str) -> MxRecord { + MxRecord { + priority: MxPriority::new(prio), + host: MxHost::parse(host).unwrap(), + } + } + + #[test] + fn prioritize_sorts_by_priority_ascending() { + let result = prioritize(vec![ + record(20, "mx2.nel.pet"), + record(10, "mx1.nel.pet"), + record(10, "mx1b.nel.pet"), + ]); + assert_eq!(result[0].priority.as_u16(), 10); + assert_eq!(result[1].priority.as_u16(), 10); + assert_eq!(result[2].priority.as_u16(), 20); + } + + #[test] + fn prioritize_randomizes_equal_priority_order() { + let attempts: Vec> = (0..200) + .map(|_| { + prioritize(vec![ + record(10, "a.nel.pet"), + record(10, "b.nel.pet"), + record(10, "c.nel.pet"), + record(10, "d.nel.pet"), + ]) + .into_iter() + .map(|r| r.host.as_str().to_string()) + .collect() + }) + .collect(); + let distinct: std::collections::HashSet<_> = attempts.iter().cloned().collect(); + assert!( + distinct.len() > 1, + "equal-priority MX order should vary across calls; got only {}", + distinct.len() + ); + } + + #[test] + fn implicit_mx_uses_domain_as_host() { + let d = EmailDomain::parse("nel.pet").unwrap(); + let result = implicit_mx(&d).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].priority.as_u16(), 0); + assert_eq!(result[0].host.as_str(), "nel.pet"); + } + + #[test] + fn no_error_response_yields_implicit_mx() { + let d = EmailDomain::parse("nel.pet").unwrap(); + let err = ResolveError::from(ResolveErrorKind::NoRecordsFound { + query: Box::new(hickory_resolver::proto::op::Query::default()), + soa: None, + negative_ttl: None, + response_code: ResponseCode::NoError, + trusted: false, + }); + let result = classify_lookup_error(err, &d).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].host.as_str(), "nel.pet"); + } + + #[test] + fn nxdomain_response_is_permanent() { + let d = EmailDomain::parse("does-not-exist.invalid").unwrap(); + let err = ResolveError::from(ResolveErrorKind::NoRecordsFound { + query: Box::new(hickory_resolver::proto::op::Query::default()), + soa: None, + negative_ttl: None, + response_code: ResponseCode::NXDomain, + trusted: true, + }); + match classify_lookup_error(err, &d) { + Err(SendError::DnsPermanent(_)) => {} + other => panic!("expected DnsPermanent, got {other:?}"), + } + } + + #[test] + fn servfail_response_is_transient() { + let d = EmailDomain::parse("nel.pet").unwrap(); + let err = ResolveError::from(ResolveErrorKind::NoRecordsFound { + query: Box::new(hickory_resolver::proto::op::Query::default()), + soa: None, + negative_ttl: None, + response_code: ResponseCode::ServFail, + trusted: false, + }); + match classify_lookup_error(err, &d) { + Err(SendError::DnsTransient(_)) => {} + other => panic!("expected DnsTransient, got {other:?}"), + } + } + + #[test] + fn timeout_is_transient() { + let d = EmailDomain::parse("nel.pet").unwrap(); + let err = ResolveError::from(ResolveErrorKind::Timeout); + match classify_lookup_error(err, &d) { + Err(SendError::DnsTransient(_)) => {} + other => panic!("expected DnsTransient, got {other:?}"), + } + } + + #[test] + fn message_variant_is_transient() { + let d = EmailDomain::parse("nel.pet").unwrap(); + let err = ResolveError::from(ResolveErrorKind::Message("transient resolver glitch")); + match classify_lookup_error(err, &d) { + Err(SendError::DnsTransient(_)) => {} + other => panic!("expected DnsTransient default, got {other:?}"), + } + } + + #[test] + fn null_mx_is_permanent() { + let d = EmailDomain::parse("nomail.nel.pet").unwrap(); + let result = interpret_lookup(vec![(0, hickory_resolver::Name::root())], &d); + match result { + Err(SendError::DnsPermanent(msg)) => { + assert!(msg.contains("null MX"), "msg: {msg}") + } + other => panic!("expected DnsPermanent, got {other:?}"), + } + } + + #[test] + fn null_mx_alongside_real_records_still_permanent() { + let d = EmailDomain::parse("mixed.nel.pet").unwrap(); + let real = hickory_resolver::Name::from_ascii("mx1.nel.pet.").unwrap(); + let result = interpret_lookup(vec![(10, real), (0, hickory_resolver::Name::root())], &d); + assert!(matches!(result, Err(SendError::DnsPermanent(_)))); + } + + #[test] + fn empty_lookup_uses_implicit_mx() { + let d = EmailDomain::parse("nel.pet").unwrap(); + let result = interpret_lookup(Vec::<(u16, hickory_resolver::Name)>::new(), &d).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].host.as_str(), "nel.pet"); + } + + #[test] + fn valid_records_pass_through_with_priority_sort() { + let d = EmailDomain::parse("nel.pet").unwrap(); + let mx1 = hickory_resolver::Name::from_ascii("mx1.nel.pet.").unwrap(); + let mx2 = hickory_resolver::Name::from_ascii("mx2.nel.pet.").unwrap(); + let result = interpret_lookup(vec![(20, mx2), (10, mx1)], &d).unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0].priority.as_u16(), 10); + assert_eq!(result[0].host.as_str(), "mx1.nel.pet"); + assert_eq!(result[1].priority.as_u16(), 20); + } +}