diff --git a/CHANGELOG.md b/CHANGELOG.md index 81c0afed..894bd66e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If - JMAP: Return RFC-3339-conformant UTCDate literals in capabilities: min `0001-01-01T00:00:00Z`, max `9999-12-31T23:59:59Z`. - Reject invalid duration values (e.g. `1h30m`). - Branding: Custom logos for domains do not work. +- Sieve: add `Received` headers to auto-generated messages and detect loops. ## [0.16.12] - 2026-07-06 diff --git a/crates/email/src/sieve/ingest.rs b/crates/email/src/sieve/ingest.rs index 80f36fcb..640c4a3e 100644 --- a/crates/email/src/sieve/ingest.rs +++ b/crates/email/src/sieve/ingest.rs @@ -13,8 +13,11 @@ use crate::{ ingest::{EmailIngest, IngestEmail, IngestSource, IngestedEmail}, }, }; -use common::{Server, auth::AccessToken, scripts::plugins::PluginContext}; -use mail_parser::MessageParser; +use common::{ + Server, auth::AccessToken, expr::functions::EmptyResolver, scripts::plugins::PluginContext, +}; +use mail_builder::headers::date::Date; +use mail_parser::{HeaderName, MessageParser}; use sieve::{Envelope, Event, Input, Mailbox, Recipient, Sieve, SpamStatus}; use std::{borrow::Cow, sync::Arc}; use std::{future::Future, str::FromStr}; @@ -26,7 +29,7 @@ use store::{ AlignedBytes, Archive, ArchiveVersion, Archiver, BatchBuilder, BlobLink, BlobOp, ValueClass, }, }; -use trc::{AddContext, SieveEvent}; +use trc::{AddContext, SieveEvent, SmtpEvent}; use types::{ blob_hash::BlobHash, collection::Collection, @@ -106,6 +109,20 @@ impl SieveScriptIngest for Server { ); }; + let received_headers = message + .headers() + .iter() + .filter(|header| matches!(header.name, HeaderName::Received)) + .count(); + let max_received_headers = self + .eval_if( + &self.core.smtp.session.data.max_received_headers, + &EmptyResolver, + session_id, + ) + .await + .unwrap_or(50); + // Obtain mailboxIds let account_id = access_token.account_id(); let mut cache = self @@ -387,6 +404,18 @@ impl SieveScriptIngest for Server { } => { input = true.into(); if let Some(message) = messages.get(message_id) { + if received_headers >= max_received_headers { + trc::event!( + Smtp(SmtpEvent::LoopDetected), + From = mail_from.clone(), + Total = received_headers, + Limit = max_received_headers, + SpanId = session_id, + ); + + continue; + } + let recipients: Vec = match recipient { Recipient::Address(rcpt) => vec![rcpt], Recipient::Group(rcpts) => rcpts, @@ -408,10 +437,19 @@ impl SieveScriptIngest for Server { SpanId = session_id ); + let mut raw_message = + Vec::with_capacity(160 + message.raw_message.len()); + write_received_header( + &mut raw_message, + &self.core.network.server_name, + session_id, + ); + raw_message.extend_from_slice(message.raw_message.as_ref()); + autogenerated.push(AutogeneratedMessage { sender_address: mail_from.clone(), recipients, - message: message.raw_message.to_vec(), + message: raw_message, }); do_redirect = true; } else { @@ -750,6 +788,16 @@ impl SieveScriptIngest for Server { } } +fn write_received_header(buf: &mut Vec, hostname: &str, id: u64) { + buf.extend_from_slice(b"Received: from localhost (localhost [127.0.0.1])\r\n\tby "); + buf.extend_from_slice(hostname.as_bytes()); + buf.extend_from_slice(b" (Stalwart SMTP) with LMTP id "); + buf.extend_from_slice(format!("{id:X}").as_bytes()); + buf.extend_from_slice(b";\r\n\t"); + buf.extend_from_slice(Date::now().to_rfc822().as_bytes()); + buf.extend_from_slice(b"\r\n"); +} + pub struct CompiledScript { pub script: Sieve, pub name: String,