From 45802ff8a68ea6d0933f98fca2d9ad0628216d1d Mon Sep 17 00:00:00 2001 From: Wez Furlong Date: Fri, 10 Jan 2025 10:31:48 -0700 Subject: [PATCH] rfc5321 client: use SMTPUTF8/8BITMIME when we have 8bit info This is not-quite SMTPUTF8 compliant; if we have 8bit data and the remote host advertises the relevant capabilities, we'll add the appropriate parameters to MAIL FROM. It's non-compliant in that we're supposed to raise an error if we have 8bit and the destination doesn't support it. Here we're assuming that the remote host will raise an error if it won't accept it. refs: https://github.com/KumoCorp/kumomta/issues/327 --- crates/rfc5321/src/client.rs | 53 ++++++++++++++++++++++++++---------- crates/rfc5321/src/parser.rs | 38 ++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 14 deletions(-) diff --git a/crates/rfc5321/src/client.rs b/crates/rfc5321/src/client.rs index 25953a00..8c902c1b 100644 --- a/crates/rfc5321/src/client.rs +++ b/crates/rfc5321/src/client.rs @@ -1,5 +1,8 @@ use crate::client_types::*; -use crate::{AsyncReadAndWrite, BoxedAsyncReadAndWrite, Command, Domain, ForwardPath, ReversePath}; +use crate::{ + AsyncReadAndWrite, BoxedAsyncReadAndWrite, Command, Domain, EsmtpParameter, ForwardPath, + ReversePath, +}; use hickory_proto::rr::rdata::tlsa::{CertUsage, Matching, Selector}; use hickory_proto::rr::rdata::TLSA; use memchr::memmem::Finder; @@ -642,15 +645,47 @@ impl SmtpClient { recipient: RECIP, data: B, ) -> Result { + let sender = sender.into(); + let recipient = recipient.into(); + + let data: &[u8] = data.as_ref(); + let stuffed; + + let data = match apply_dot_stuffing(data) { + Some(d) => { + stuffed = d; + &stuffed + } + None => data, + }; + + let data_is_8bit = data.iter().any(|&b| b >= 0x80); + let envelope_is_8bit = !sender.is_ascii() || !recipient.is_ascii(); + + let mut mail_from_params = vec![]; + if data_is_8bit && self.capabilities.contains_key("8BITMIME") { + mail_from_params.push(EsmtpParameter { + name: "BODY".to_string(), + value: Some("8BITMIME".to_string()), + }); + } + + if envelope_is_8bit && self.capabilities.contains_key("SMTPUTF8") { + mail_from_params.push(EsmtpParameter { + name: "SMTPUTF8".to_string(), + value: None, + }); + } + let mut responses = self .pipeline_commands(vec![ Command::Rset, Command::MailFrom { - address: sender.into(), - parameters: vec![], + address: sender, + parameters: mail_from_params, }, Command::RcptTo { - address: recipient.into(), + address: recipient, parameters: vec![], }, Command::Data, @@ -704,16 +739,6 @@ impl SmtpClient { return Err(ClientError::Rejected(data_resp)); } - let data: &[u8] = data.as_ref(); - let stuffed; - - let data = match apply_dot_stuffing(data) { - Some(d) => { - stuffed = d; - &stuffed - } - None => data, - }; let needs_newline = data.last().map(|&b| b != b'\n').unwrap_or(true); tracing::trace!("message data is {} bytes", data.len()); diff --git a/crates/rfc5321/src/parser.rs b/crates/rfc5321/src/parser.rs index b2e1a01c..ca52a1b5 100644 --- a/crates/rfc5321/src/parser.rs +++ b/crates/rfc5321/src/parser.rs @@ -204,6 +204,15 @@ pub enum ReversePath { NullSender, } +impl ReversePath { + pub fn is_ascii(&self) -> bool { + match self { + Self::Path(path) => path.is_ascii(), + Self::NullSender => true, + } + } +} + impl TryFrom<&str> for ReversePath { type Error = String; fn try_from(s: &str) -> Result { @@ -241,6 +250,15 @@ pub enum ForwardPath { Postmaster, } +impl ForwardPath { + pub fn is_ascii(&self) -> bool { + match self { + Self::Path(p) => p.is_ascii(), + Self::Postmaster => true, + } + } +} + impl TryFrom<&str> for ForwardPath { type Error = String; fn try_from(s: &str) -> Result { @@ -280,6 +298,14 @@ pub struct MailPath { pub mailbox: Mailbox, } +impl MailPath { + pub fn is_ascii(&self) -> bool { + // Note: ignoring at_domain_list here per the to_string() + // implementation + self.mailbox.is_ascii() + } +} + impl ToString for MailPath { fn to_string(&self) -> String { // Note: RFC5321 says about at_domain_list: @@ -298,6 +324,18 @@ pub struct Mailbox { pub domain: Domain, } +impl Mailbox { + pub fn is_ascii(&self) -> bool { + if !self.local_part.is_ascii() { + return false; + } + match &self.domain { + Domain::V4(s) | Domain::V6(s) | Domain::Name(s) => s.is_ascii(), + Domain::Tagged { tag, literal } => tag.is_ascii() && literal.is_ascii(), + } + } +} + impl ToString for Mailbox { fn to_string(&self) -> String { let domain = self.domain.to_string();