diff --git a/crates/kumo-api-types/src/egress_path.rs b/crates/kumo-api-types/src/egress_path.rs index 960ced81b..e913f428d 100644 --- a/crates/kumo-api-types/src/egress_path.rs +++ b/crates/kumo-api-types/src/egress_path.rs @@ -250,6 +250,10 @@ pub struct EgressPathConfig { /// TLS disabled. #[serde(default)] pub opportunistic_tls_reconnect_on_failed_handshake: bool, + + /// If true, rather than ESMTP, use the LMTP protocol + #[serde(default)] + pub use_lmtp: bool, } #[cfg(feature = "lua")] @@ -289,6 +293,7 @@ impl Default for EgressPathConfig { provider_name: None, remember_broken_tls: None, opportunistic_tls_reconnect_on_failed_handshake: false, + use_lmtp: false, } } } diff --git a/crates/kumo-api-types/src/shaping.rs b/crates/kumo-api-types/src/shaping.rs index 7bce17031..8cbc8b191 100644 --- a/crates/kumo-api-types/src/shaping.rs +++ b/crates/kumo-api-types/src/shaping.rs @@ -1889,6 +1889,7 @@ MergedEntry { provider_name: None, remember_broken_tls: None, opportunistic_tls_reconnect_on_failed_handshake: false, + use_lmtp: false, }, sources: {}, automation: [ @@ -2024,6 +2025,7 @@ MergedEntry { provider_name: None, remember_broken_tls: None, opportunistic_tls_reconnect_on_failed_handshake: false, + use_lmtp: false, }, sources: { "my source name": EgressPathConfig { @@ -2072,6 +2074,7 @@ MergedEntry { provider_name: None, remember_broken_tls: None, opportunistic_tls_reconnect_on_failed_handshake: false, + use_lmtp: false, }, }, automation: [ @@ -2213,6 +2216,7 @@ MergedEntry { provider_name: None, remember_broken_tls: None, opportunistic_tls_reconnect_on_failed_handshake: false, + use_lmtp: false, }, sources: {}, automation: [ diff --git a/crates/kumod/src/smtp_dispatcher.rs b/crates/kumod/src/smtp_dispatcher.rs index f58369d9a..e8b378c24 100644 --- a/crates/kumod/src/smtp_dispatcher.rs +++ b/crates/kumod/src/smtp_dispatcher.rs @@ -346,11 +346,12 @@ impl SmtpDispatcher { .with_context(|| connect_context.clone())?; self.source_address.replace(source_address); - // Say EHLO + // Say EHLO/LHLO + let helo_verb = if path_config.use_lmtp { "LHLO" } else { "EHLO" }; let pretls_caps = client - .ehlo(&ehlo_name) + .ehlo_lhlo(&ehlo_name, path_config.use_lmtp) .await - .with_context(|| format!("{address}:{port}: EHLO after banner"))?; + .with_context(|| format!("{address}:{port}: {helo_verb} after banner"))?; // Use STARTTLS if available. let has_tls = pretls_caps.contains_key("STARTTLS"); @@ -531,7 +532,7 @@ impl SmtpDispatcher { // incorrectly roll over failed TLS into the following command, // and we want to consider those as connection errors rather than // having them show up per-message in MAIL FROM - match client.ehlo(&ehlo_name).await { + match client.ehlo_lhlo(&ehlo_name, path_config.use_lmtp).await { Ok(_) => enabled, Err(error) => { self.remember_broken_tls(&dispatcher.name, &path_config); @@ -603,9 +604,9 @@ impl SmtpDispatcher { } match client - .ehlo(&ehlo_name) + .ehlo_lhlo(&ehlo_name, path_config.use_lmtp) .await - .with_context(|| format!("{address:?}:{port}: EHLO after STARTTLS")) + .with_context(|| format!("{address:?}:{port}: {helo_verb} after STARTTLS")) { Ok(_) => true, Err(err) => { diff --git a/crates/kumod/src/smtp_server.rs b/crates/kumod/src/smtp_server.rs index c8145a9f4..eeeb77e30 100644 --- a/crates/kumod/src/smtp_server.rs +++ b/crates/kumod/src/smtp_server.rs @@ -1595,7 +1595,7 @@ impl SmtpServer { self.write_response(250, "the goggles do nothing", None) .await?; } - Ok(Command::Vrfy(_) | Command::Expn(_) | Command::Help(_)) => { + Ok(Command::Vrfy(_) | Command::Expn(_) | Command::Help(_) | Command::Lhlo(_)) => { self.write_response(502, format!("5.5.1 Command unimplemented"), Some(line)) .await?; } diff --git a/crates/rfc5321/src/client.rs b/crates/rfc5321/src/client.rs index 1bb2a8df0..25953a00f 100644 --- a/crates/rfc5321/src/client.rs +++ b/crates/rfc5321/src/client.rs @@ -429,6 +429,28 @@ impl SmtpClient { results } + pub async fn ehlo_lhlo( + &mut self, + ehlo_name: &str, + use_lmtp: bool, + ) -> Result<&HashMap, ClientError> { + if use_lmtp { + self.lhlo(ehlo_name).await + } else { + self.ehlo(ehlo_name).await + } + } + + pub async fn lhlo( + &mut self, + ehlo_name: &str, + ) -> Result<&HashMap, ClientError> { + let response = self + .send_command(&Command::Lhlo(Domain::Name(ehlo_name.to_string()))) + .await?; + self.ehlo_common(response) + } + pub async fn ehlo( &mut self, ehlo_name: &str, @@ -436,6 +458,13 @@ impl SmtpClient { let response = self .send_command(&Command::Ehlo(Domain::Name(ehlo_name.to_string()))) .await?; + self.ehlo_common(response) + } + + fn ehlo_common( + &mut self, + response: Response, + ) -> Result<&HashMap, ClientError> { if response.code != 250 { return Err(ClientError::Rejected(response)); } diff --git a/crates/rfc5321/src/parser.rs b/crates/rfc5321/src/parser.rs index 83e9f7f92..c04a63c6a 100644 --- a/crates/rfc5321/src/parser.rs +++ b/crates/rfc5321/src/parser.rs @@ -343,6 +343,7 @@ impl ToString for EsmtpParameter { pub enum Command { Ehlo(Domain), Helo(Domain), + Lhlo(Domain), MailFrom { address: ReversePath, parameters: Vec, @@ -375,6 +376,7 @@ impl Command { match self { Self::Ehlo(domain) => format!("EHLO {}\r\n", domain.to_string()), Self::Helo(domain) => format!("HELO {}\r\n", domain.to_string()), + Self::Lhlo(domain) => format!("LHLO {}\r\n", domain.to_string()), Self::MailFrom { address, parameters, @@ -424,7 +426,7 @@ impl Command { /// Timeouts for reading the response pub fn client_timeout(&self, timeouts: &SmtpClientTimeouts) -> Duration { match self { - Self::Helo(_) | Self::Ehlo(_) => timeouts.ehlo_timeout, + Self::Helo(_) | Self::Ehlo(_) | Self::Lhlo(_) => timeouts.ehlo_timeout, Self::MailFrom { .. } => timeouts.mail_from_timeout, Self::RcptTo { .. } => timeouts.rcpt_to_timeout, Self::Data { .. } => timeouts.data_timeout, diff --git a/docs/reference/kumo/make_egress_path/use_lmtp.md b/docs/reference/kumo/make_egress_path/use_lmtp.md new file mode 100644 index 000000000..e4e61f70e --- /dev/null +++ b/docs/reference/kumo/make_egress_path/use_lmtp.md @@ -0,0 +1,8 @@ +# use_lmtp + +{{since('dev')}} + +When set to `true` (the default is `false`), use the `LMTP` protocol +as described in [RFC 2033](https://www.rfc-editor.org/rfc/rfc2033) +rather than the `ESMTP` protocol. +