Add footer

- Add a footer to each mail resulting from a split into an encrypted and
  an unencrypted version. The footer contains the recipients of the mail
  which are excluded in the corresponding stream.
- Move the construction of the `HuskContext` to the `mail`
  milter-command, as `helo` is used by scrapers to detect mailservers.
- Use `CertD::trust_root()` to get the trust root - it creates one if no
  root can be found.
- Add `chrono` dependency.
This commit is contained in:
Malte Meiboom
2026-07-14 11:28:34 +02:00
parent 8b2f284916
commit d54cc480b4
6 changed files with 102 additions and 54 deletions
+8 -29
View File
@@ -23,7 +23,6 @@ use sequoia_openpgp::parse::{PacketParser, PacketParserResult, Parse};
use sequoia_openpgp::policy::StandardPolicy;
use sequoia_openpgp::serialize::stream::{Message, Armorer, Encryptor, Signer, LiteralWriter};
use sequoia_openpgp::{Fingerprint, Cert, Packet};
use sequoia_openpgp::cert::raw::RawCertParser;
use sequoia_openpgp::types::SignatureType;
use sequoia_cert_store::{Store, CertStore, LazyCert, StoreUpdate};
use sequoia_cert_store::store::MergePublicCollectStats;
@@ -45,35 +44,15 @@ const HUSK_SIG_NOTATION: &str = "signing_key@husk-project.org";
/// Returns the local trust root from the cert_store. If the trust root
/// cannot be found, it returns an error.
pub fn get_local_trust_root(cert_store: &CertStore) -> anyhow::Result<Cert> {
let root = cert_store.certd()
.and_then(|certd| {
match certd.certd().get(sequoia_cert_store::store::openpgp_cert_d::TRUST_ROOT) {
Ok(Some((_tag, bytes))) => Some(bytes),
Ok(None) => None,
Err(_) => None
}
})
.and_then(|bytes| {
match RawCertParser::from_bytes(&bytes[..]) {
Ok(mut parser) => {
match parser.next() {
Some(Ok(cert)) => {
Cert::from_bytes(cert.as_bytes()).ok()
},
Some(Err(_))
| None => None
}
},
Err(_) => None,
}
});
match root {
Some(r) => Ok(r),
None => Err(HuskError::NoLocalTrustRoot.into())
if let Some((root, created)) = cert_store.certd().and_then(|certd| {
certd.trust_root().ok() }) {
if created {
log::info!("Local trust root not found - created one");
}
Ok(root.to_cert()?.clone())
} else {
Err(HuskError::NoLocalTrustRoot.into())
}
}
/// Returns all local certificates with an authenticated binding to user id 'email'.
+30 -22
View File
@@ -1,7 +1,7 @@
//! the actual daemon
use anyhow;
use bytes::Bytes;
use bytes::{Bytes, BytesMut};
use std::ffi::CString;
use tokio::signal;
use tokio::net::TcpListener;
@@ -39,8 +39,8 @@ impl Daemon {
let callbacks = Callbacks::new()
.on_negotiate(|cx, actions, opts| Box::pin(Self::handle_negotiate(cx, actions, opts)))
.on_connect(|cx, hostname, socket_info| Box::pin(Self::handle_connect(cx, hostname, socket_info)))
.on_helo(move |cx, hostname| Box::pin(Self::handle_helo(cx, config_container.clone(), hostname)))
.on_mail(|cx, args| Box::pin(Self::handle_mail(cx, args)))
.on_helo(|cx, hostname| Box::pin(Self::handle_helo(cx, hostname)))
.on_mail(move |cx, args| Box::pin(Self::handle_mail(cx, config_container.clone(), args)))
.on_rcpt(|cx, args| Box::pin(Self::handle_rcpt(cx, args)))
.on_data(|cx| Box::pin(Self::handle_data(cx)))
.on_header(|cx, name, value| Box::pin(Self::handle_header(cx, name, value)))
@@ -80,26 +80,15 @@ impl Daemon {
Status::Continue
}
async fn handle_helo(cx: &mut Context<HuskContext<'_>>, config_container: HuskConfigContainer, hostname: CString) -> Status {
async fn handle_helo(_cx: &mut Context<HuskContext<'_>>, hostname: CString) -> Status {
log::debug!("HELO: hostname: {hostname:?}");
let config: HuskConfig = config_container.into();
// XXX: check for reinjection
// setup Context
match HuskContext::new(&config) {
Ok(context) => {
log::debug!("found introducers: {:?}", context.introducers);
cx.data = Some(context);
Status::Continue
},
Err(e) => {
log::error!("{}", e);
Status::Tempfail
}
}
Status::Continue
}
async fn handle_mail(cx: &mut Context<HuskContext<'_>>, args: Vec<CString>) -> Status {
async fn handle_mail(cx: &mut Context<HuskContext<'_>>, config_container: HuskConfigContainer, args: Vec<CString>) -> Status {
log::debug!("MAIL: {args:?}");
let mut sender: Option<String> = None;
@@ -117,7 +106,20 @@ impl Daemon {
context.set_sender(sender.unwrap());
Status::Continue
} else {
Status::Tempfail
let config: HuskConfig = config_container.into();
// setup Context
match HuskContext::new(&config) {
Ok(mut context) => {
context.set_sender(sender.unwrap());
cx.data = Some(context);
Status::Continue
},
Err(e) => {
log::error!("{}", e);
Status::Tempfail
}
}
}
}
@@ -213,7 +215,9 @@ impl Daemon {
match ProtectionPossibility::from(&context.mail) {
ProtectionPossibility::Full => {
log::debug!("completely encryptable");
let inner_mail = MailContext::generate_inner_mail(&context.mail.header, body);
let mut body: BytesMut = body.into();
body.extend_from_slice(mail::generate_footer(&vec![]).as_bytes());
let inner_mail = MailContext::generate_inner_mail(&context.mail.header, body.into());
// if an error occures the body is not exchanged
match crypto::encrypt(context, inner_mail.to_string().as_bytes(), &context.mail.recipients) {
@@ -257,19 +261,23 @@ impl Daemon {
log::debug!("partially encryptable");
// Generate a new mail for the unencryptable recipients.
let mut unencryted_body: BytesMut = body.clone().into();
unencryted_body.extend_from_slice(mail::generate_footer(&encryptable).as_bytes());
let header = context.mail.header.without_recipients(&encryptable);
if mail::smtp_inject(
from,
&unencryptable,
header,
&body).await.is_err() {
&unencryted_body.into()).await.is_err() {
// smtp_inject() already logged the error.
return Status::Tempfail;
}
// Encrypt for remaining recipients.
let mut encrypted_body: BytesMut = body.into();
encrypted_body.extend_from_slice(mail::generate_footer(&unencryptable).as_bytes());
let header = context.mail.header.without_recipients(&unencryptable);
let inner_mail = MailContext::generate_inner_mail(&header, body);
let inner_mail = MailContext::generate_inner_mail(&header, encrypted_body.into());
// If an error occures, the body is not exchanged.
match crypto::encrypt(context, inner_mail.to_string().as_bytes(), &encryptable) {
+54
View File
@@ -11,8 +11,10 @@ use lettre::transport::smtp::{
use lettre::Address;
use tokio::task::JoinSet;
use std::time::Duration;
use chrono::Utc;
use crate::types::cheader::CHeader;
use crate::types::defaults;
use crate::types::errors::HuskError;
use crate::types::recipient::Recipient;
@@ -128,3 +130,55 @@ pub async fn smtp_inject(from: String, to: &Vec<Recipient<'_>>, header: CHeader,
}
}
pub fn generate_footer(rcpts: &Vec<Recipient>) -> String {
internal_generate_footer(Utc::now().to_rfc2822(), rcpts)
}
pub(crate) fn internal_generate_footer(now: String, other_rpcts: &Vec<Recipient>) -> String {
let mut footer = defaults::FOOTER.replace("{date}", now.as_str());
if ! other_rpcts.is_empty() {
let rcpts = other_rpcts.iter()
.map(|r| r.email.clone())
.collect::<Vec<String>>()
.join(", ");
footer.push_str(
defaults::FOOTER_RECIPIENTS.replace(
"{rcpts}",
&rcpts).as_str()
);
}
footer
}
#[cfg(test)]
pub mod tests {
use crate::mail::internal_generate_footer;
use crate::types::recipient::Recipient;
#[test]
fn test_footer() {
let rcpts = vec![
Recipient {
email: "alice@example.com".to_string(),
certs: Vec::new()
},
Recipient {
email: "bob@example.com".to_string(),
certs: Vec::new()
}
];
let footer = internal_generate_footer(
"1.1.1970".to_string(),
&rcpts
);
assert!(footer.contains("1.1.1970"));
assert!(footer.contains("alice@example.com"));
assert!(footer.contains("bob@example.com"));
}
}
+3
View File
@@ -8,6 +8,9 @@ pub const SUBJECT_REPLACEMENT: &str = "...";
pub const PID_FILE_LOCATION: &str = "/run/husk/pid";
pub const FOOTER: &str = "---\nProcessed by Husk at {date}.\n";
pub const FOOTER_RECIPIENTS: &str = "This mail was also send to {rcpts}.\n";
/// Default logging configuration.
pub const CONSOLE_LOGGING: &str = "
[appenders.console]