refactor: Use a custom, minimal SMTP client instead of lettre (#33)

This disables the Nagle's algorithm on re-insertion.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
This commit is contained in:
Jagoda Estera Ślązak
2026-02-06 11:15:14 +01:00
committed by GitHub
parent 8d18b73681
commit 0ee1e9924c
8 changed files with 119 additions and 570 deletions
+19
View File
@@ -10,4 +10,23 @@ pub enum Error {
Io(#[from] std::io::Error),
#[error("OpenPGP packet header is truncated - can't validate!")]
TruncatedHeader,
#[error("Unable to send email, Error during {context}, server said: {raw_smtp_answer}")]
MailSend {
context: String,
raw_smtp_answer: String,
},
}
impl Error {
/// Formats [`Error`] as an SMTP response.
pub fn smtp_response(&self) -> String {
match self {
// We transparently pass postfix/milter errors reported on reinjection
Error::MailSend {
raw_smtp_answer, ..
} => raw_smtp_answer.clone(),
Error::TruncatedHeader => self.to_string(),
_ => "451 Local error".to_string(),
}
}
}
+6 -25
View File
@@ -5,11 +5,10 @@ use crate::config::Config;
use crate::message::{check_encrypted, is_securejoin};
use crate::smtp_server::SmtpHandler;
use async_trait::async_trait;
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
use mailparse::{MailHeaderMap, parse_mail};
pub use crate::smtp_server::Envelope;
use crate::utils::{extract_address, format_smtp_error};
use crate::utils::extract_address;
/// Handler for incoming SMTP messages.
pub struct IncomingBeforeQueueHandler {
@@ -80,30 +79,12 @@ impl SmtpHandler for IncomingBeforeQueueHandler {
async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String> {
log::debug!("Re-injecting the mail that passed checks");
let mailer = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous("localhost")
.port(self.config.postfix_reinject_port_incoming)
.build();
let envelope_data = lettre::address::Envelope::new(
Some(
envelope
.mail_from
.parse()
.map_err(|e| format!("Invalid from address: {}", e))?,
),
envelope
.rcpt_to
.iter()
.map(|addr| addr.parse())
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Invalid to address: {}", e))?,
)
.map_err(|e| format!("Failed to create envelope: {}", e))?;
mailer
.send_raw(&envelope_data, &envelope.data)
crate::smtp_client::send(self.config.postfix_reinject_port_incoming, envelope)
.await
.map_err(format_smtp_error)?;
.map_err(|e| {
log::warn!("Failed to re-inject mail: {}", e);
e.smtp_response()
})?;
Ok(())
}
+1
View File
@@ -30,6 +30,7 @@ pub(crate) mod inbound;
pub(crate) mod message;
pub(crate) mod openpgp;
pub(crate) mod outbound;
pub(crate) mod smtp_client;
pub(crate) mod smtp_server;
pub(crate) mod utils;
+6 -25
View File
@@ -5,10 +5,9 @@ use crate::config::Config;
use crate::message::{check_encrypted, is_securejoin, recipient_matches_passthrough};
pub use crate::smtp_server::Envelope;
use crate::smtp_server::SmtpHandler;
use crate::utils::{extract_address, format_smtp_error};
use crate::utils::extract_address;
use async_trait::async_trait;
use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter};
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
use mailparse::{MailHeaderMap, parse_mail};
/// Handler for outgoing SMTP messages.
@@ -127,30 +126,12 @@ impl SmtpHandler for OutgoingBeforeQueueHandler {
async fn reinject_mail(&self, envelope: &Envelope) -> Result<(), String> {
log::debug!("Re-injecting the mail that passed checks");
let mailer = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous("localhost")
.port(self.config.postfix_reinject_port)
.build();
let envelope_data = lettre::address::Envelope::new(
Some(
envelope
.mail_from
.parse()
.map_err(|e| format!("Invalid from address: {}", e))?,
),
envelope
.rcpt_to
.iter()
.map(|addr| addr.parse())
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Invalid to address: {}", e))?,
)
.map_err(|e| format!("Failed to create envelope: {}", e))?;
mailer
.send_raw(&envelope_data, &envelope.data)
crate::smtp_client::send(self.config.postfix_reinject_port, envelope)
.await
.map_err(format_smtp_error)?;
.map_err(|e| {
log::warn!("Failed to re-inject mail: {}", e);
e.smtp_response()
})?;
Ok(())
}
+78
View File
@@ -0,0 +1,78 @@
use crate::smtp_server::Envelope;
use std::net::{IpAddr, SocketAddr};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufStream};
use tokio::net::TcpSocket;
const LOCALHOST: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1));
/// Sends an email using an SMTP server at `localhost:<smtp_port>`.
pub async fn send(smtp_port: u16, envelope: &Envelope) -> Result<(), crate::error::Error> {
let socket = TcpSocket::new_v4()?;
// Disable Nagle's algorithm.
socket.set_nodelay(true)?;
let stream = socket
.connect(SocketAddr::new(LOCALHOST, smtp_port))
.await?;
let mut buf_stream = BufStream::new(stream);
let mut response = String::new();
macro_rules! smtp_write {
($command: expr) => {
buf_stream.write_all($command).await?;
buf_stream.flush().await?;
};
}
macro_rules! smtp_read {
($context:expr, $expected_code:expr) => {
buf_stream.read_line(&mut response).await?;
if !response.starts_with($expected_code) {
return Err(crate::error::Error::MailSend {
context: $context.to_string(),
raw_smtp_answer: response.clone(),
});
}
response.clear();
};
}
macro_rules! smtp_cmd {
($command:expr, $context:expr, $expected_code:expr) => {
smtp_write!($command);
smtp_read!($context, $expected_code);
};
}
// Read initial greeting
smtp_read!("initial greeting", "220");
// Greet (Using HELO as we don't want to deal with extended SMTP anyway.)
smtp_cmd!(b"HELO localhost\r\n", "HELO", "250");
// MAIL FROM
smtp_cmd!(
format!("MAIL FROM:<{}>\r\n", envelope.mail_from).as_bytes(),
"MAIL FROM",
"250"
);
// RCPT TO
for rcpt in &envelope.rcpt_to {
smtp_cmd!(
format!("RCPT TO:<{}>\r\n", rcpt).as_bytes(),
"RCPT TO",
"250"
);
}
// DATA
smtp_cmd!(b"DATA\r\n", "DATA", "354");
smtp_write!(&envelope.data);
smtp_write!(b".\r\n");
smtp_read!("end of DATA", "250");
Ok(())
}
-24
View File
@@ -1,5 +1,4 @@
use mailparse::MailAddr;
use std::error::Error;
/// Extracts the first email address found in SMTP command or email header.
///
@@ -27,29 +26,6 @@ pub fn extract_address(input: &str) -> Option<String> {
})
}
/// Formats SMTP error to be able to send it back to postfix.
pub fn format_smtp_error(error: lettre::transport::smtp::Error) -> String {
if let Some(code) = error.status() {
format!(
"{} {}",
code,
error
.source()
.map(ToString::to_string)
.unwrap_or("Unknown error".to_string())
)
} else {
// Default to 451, most probably means some internal service error (e.g. milter)
format!(
"451 {}",
error
.source()
.map(ToString::to_string)
.unwrap_or("Unknown error".to_string())
)
}
}
#[cfg(test)]
mod tests {
use super::*;