Add mail splitting

- If a mail has more then one recipient and not for all recipients an
  authenticated certificate can be obtained, the mail processing is
  split into 2 streams - one that encrypts and an other which sends the
  mail in plaintext.
- For both streams the header and the list of recipients is adjusted
  accordingly.
- As the milter interface only allows one stream, the second stream is
  reinjected into the MTA (via SMTP). The MTA is hardcoded to be
  localhost:25.
This commit is contained in:
Malte Meiboom
2026-07-09 12:49:47 +02:00
parent 94c94d1fce
commit ca58d73050
5 changed files with 395 additions and 56 deletions
+96
View File
@@ -1,8 +1,22 @@
//! mail functions
use anyhow::Result;
use bytes::Bytes;
use bytes::BytesMut;
use std::ffi::CString;
use sequoia_openpgp::packet::UserID;
use lettre::transport::smtp::{
client::SmtpConnection, commands::*, extension::ClientId, SMTP_PORT,
};
use lettre::Address;
use tokio::task::JoinSet;
use std::time::Duration;
use crate::types::cheader::CHeader;
use crate::types::errors::HuskError;
use crate::types::recipient::Recipient;
/// Parse the user id fields from a string.
pub fn to_userid(rcpt: &CString) -> Option<UserID> {
match rcpt.to_str() {
Ok(s) => Some(UserID::from(s)),
@@ -10,6 +24,7 @@ pub fn to_userid(rcpt: &CString) -> Option<UserID> {
}
}
/// Get the email from the string representation of a user id.
pub fn to_email(rcpt: &CString) -> Option<String> {
if let Some(u) = to_userid(rcpt) {
if let Ok(Some(e)) = u.email() { Some(e.to_string()) }
@@ -32,3 +47,84 @@ pub fn is_email(email: &str) -> bool {
pub fn is_domain(domain: &str) -> bool {
is_email(format!("test@{}", domain).as_str())
}
pub async fn smtp_inject(from: String, to: &Vec<Recipient<'_>>, header: CHeader, body: &Bytes) -> Result<()> {
let mut joinset: JoinSet<Result<()>> = JoinSet::new();
log::debug!("smtp inject: from {}", from);
// build message
let mut header_size = 0;
for (key, value) in header.iter_lines() {
header_size += key.count_bytes();
header_size += value.count_bytes();
header_size += 4; // 4 is ': ' plus '\r\n'
}
// The size of the whole message the sum of header and body size + '\r\n'
// as the dividing empty line.
let mut message = BytesMut::with_capacity(header_size + body.len() + 2);
for (key, value) in header.iter_lines() {
message.extend_from_slice(key.as_bytes());
message.extend_from_slice(": ".as_bytes());
message.extend_from_slice(value.as_bytes());
message.extend_from_slice("\r\n".as_bytes());
}
message.extend_from_slice(b"\r\n");
message.extend_from_slice(&body[..]);
// convert addresses
let from_email = CString::new(from.clone())?;
let from_email = to_email(&from_email).unwrap(); // XXX
let from_email: Address = from_email.try_into()?;
let mut rcpts: Vec<Address> = Vec::new();
for rcpt in to {
let rcpt = rcpt.email.clone();
rcpts.push(rcpt.try_into()?);
}
let my_id = ClientId::Domain("husk.reinject".to_owned()); // TODO: add secret token?
joinset.spawn(async move {
// XXX
// We connect to localhost - every other host would require implementing
// TLS. The milter protocol is unencrypted anyways.
match SmtpConnection::connect(("localhost", SMTP_PORT), Some(Duration::from_secs(5)), &my_id, None, None) {
Ok(mut client) => {
log::debug!("connected - start sending");
client.command(Mail::new(Some(from_email),vec![]))?;
for rcpt in rcpts {
client.command(Rcpt::new(rcpt, vec![]))?;
}
client.command(Data)?;
client.message(&message)?;
client.command(Quit)?;
Ok(())
},
Err(e) => {
log::error!("cannot connect to localhost:{} - {:?}", SMTP_PORT, e);
Err(e.into())
}
}
});
match joinset.join_all().await.first() {
Some(Ok(_)) => {
log::debug!("mail injection done.");
Ok(())
},
Some(Err(e)) => {
log::error!("mail injection failed: {:?}", e);
Err(HuskError::SmtpInjectionFailure(e.to_string()).into())
},
_ => {
log::error!("mail injection failed without reason");
Err(HuskError::SmtpInjectionFailure("no reason".to_string()).into())
},
}
}