Sieve: don't answer auto-submitted mail #235

Merged
jcoffey-dev merged 1 commits from fix/sieve-auto-submitted into main 2026-10-29 17:33:38 +00:00
4 changed files with 77 additions and 16 deletions

No files matched your search

@@ -348,4 +348,51 @@ if header :regex "x-h" "${p}" { discard; }
let many = run(1000);
assert!(many < one * 10, "1 header: {one:?}, 1000 headers: {many:?}");
}
// inbuxa: vacation and notify don't answer mail a program sent (RFC 3834),
// or two autoresponders answer each other
#[test]
fn sieve_auto_submitted_mail_gets_no_reply() {
let runtime = sieve::Runtime::new();
let sends = |script: &str, header: &str| {
let script =
std::sync::Arc::new(sieve::Compiler::new().compile(script.as_bytes()).unwrap());
let message = format!(
"From: [email protected]\r\nTo: [email protected]\r\n{header}Subject: hi\r\n\r\nbody\r\n"
);
let mut ctx = runtime
.filter(message.as_bytes())
.with_envelope(sieve::Envelope::From, "[email protected]")
.with_envelope(sieve::Envelope::To, "[email protected]")
.with_user_address("[email protected]");
let mut input = sieve::Input::script("test", script);
let mut sent = false;
while let Some(event) = ctx.run(input) {
input = match event.unwrap() {
sieve::Event::SendMessage { .. } => {
sent = true;
sieve::Input::True
}
sieve::Event::DuplicateId { .. } => sieve::Input::False,
_ => sieve::Input::True,
};
}
sent
};
for script in [
"require \"vacation\";\nvacation \"away\";\n",
"require \"enotify\";\nnotify \"mailto:[email protected]\";\n",
] {
assert!(sends(script, ""), "{script}");
assert!(sends(script, "Auto-Submitted: no\r\n"), "{script}");
assert!(
!sends(script, "Auto-Submitted: auto-replied\r\n"),
"{script}"
);
assert!(
!sends(script, "auto-submitted: auto-generated\r\n"),
"{script}"
);
}
}
}
+8
View File
@@ -42,6 +42,14 @@ Every changed file carries the AGPL 5(a) notice in its header.
in `context.rs` cover it; they run from this directory with `cargo test
--offline --target-dir ../../target/sieve-rs-tests scan_limit` (the crate's
`test_suite` and `parse_rfc` need upstream's `tests/`, which isn't kept).
- `src/runtime/actions/action_vacation.rs`, `action_notify.rs`: vacation
and notify stay silent for mail with `Auto-Submitted` other than `no`
(RFC 3834). Upstream looked for the header as `HeaderName::Other`, but
mail-parser 0.11 parses it as `HeaderName::AutoSubmitted`, so the check
never ran and both answered other programs' mail, autoresponders included.
Upstream 0.7.3 fails its own svtests "No reply to auto-submitted mail" and
"Notifying on automated messages" for this reason. The
`sieve_auto_submitted_mail_gets_no_reply` test in `crates/common` covers it.
- `Cargo.toml`: the example target is dropped, with the top-level
`examples/` and `tests/`, which the server never builds. (`src/` keeps its
own `tests` modules: the crate declares them.)
+6 -7
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use mail_builder::headers::{date::Date, message_id::generate_message_id_header};
@@ -15,18 +17,15 @@ use crate::{
},
};
use super::action_vacation::MAX_SUBJECT_LEN;
use super::action_vacation::{MAX_SUBJECT_LEN, is_auto_submitted};
impl Notify {
pub(crate) fn exec(&self, ctx: &mut Context) {
// Do not notify on Auto-Submitted messages
for header in &ctx.message.parts[0].headers {
if matches!(&header.name, HeaderName::Other(name) if name.eq_ignore_ascii_case("Auto-Submitted"))
&& header
.value
.as_text()
.is_none_or(|v| !v.eq_ignore_ascii_case("no"))
{
// inbuxa: mail-parser names Auto-Submitted itself; matching
// HeaderName::Other never found it
if header.name == HeaderName::AutoSubmitted && is_auto_submitted(&header.value) {
return;
}
}
+16 -9
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use std::borrow::Cow;
@@ -23,6 +25,14 @@ use crate::{
pub(crate) const MAX_SUBJECT_LEN: usize = 256;
// inbuxa: RFC 3834: anything but "no" means a program sent the message, and
// a program's mail is not answered
pub(crate) fn is_auto_submitted(value: &HeaderValue) -> bool {
value
.as_text()
.is_none_or(|v| !v.eq_ignore_ascii_case("no"))
}
impl TestVacation {
pub(crate) fn exec(&self, ctx: &mut Context) -> TestResult {
let mut from = String::new();
@@ -101,16 +111,13 @@ impl TestVacation {
HeaderName::Received => {
received_count += 1;
}
// inbuxa: mail-parser names Auto-Submitted itself, so it never
// arrived as HeaderName::Other and the check never ran
HeaderName::AutoSubmitted if is_auto_submitted(&header.value) => {
return TestResult::Bool(false);
}
HeaderName::Other(header_name) => {
if header_name.eq_ignore_ascii_case("Auto-Submitted") {
if header
.value
.as_text()
.is_none_or(|v| !v.eq_ignore_ascii_case("no"))
{
return TestResult::Bool(false);
}
} else if header_name.eq_ignore_ascii_case("X-Auto-Response-Suppress") {
if header_name.eq_ignore_ascii_case("X-Auto-Response-Suppress") {
if header.value.as_text().is_some_and(|v| {
v.to_ascii_lowercase()
.split(',')