From 9bb74298a21679af4ca8a13d22e3ad9e969cfa36 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Thu, 29 Oct 2026 10:23:16 -0700 Subject: [PATCH] Sieve: don't answer auto-submitted mail Vacation and notify are meant to stay silent for mail with an Auto-Submitted header other than "no" (RFC 3834, RFC 5230 section 4.6). The vendored sieve-rs looked for that header as HeaderName::Other, but mail-parser 0.11 parses it as HeaderName::AutoSubmitted, so the check never matched: an out-of-office reply (a JMAP VacationResponse compiles to the same vacation action) or a Sieve notify answered other programs' mail, other autoresponders included. The per-sender duplicate check kept a loop with another responder to one reply per period. Both now match HeaderName::AutoSubmitted. Upstream 0.7.3 fails its own svtests "No reply to auto-submitted mail" and "Notifying on automated messages" for this reason; with the fix both pass, with the rest of the crate's tests. sieve_auto_submitted_mail_gets_no_reply in crates/common covers vacation and notify, and fails without the fix. --- crates/common/src/config/mailstore/scripts.rs | 47 +++++++++++++++++++ vendor/sieve-rs/VENDORED.md | 8 ++++ .../src/runtime/actions/action_notify.rs | 13 +++-- .../src/runtime/actions/action_vacation.rs | 25 ++++++---- 4 files changed, 77 insertions(+), 16 deletions(-) diff --git a/crates/common/src/config/mailstore/scripts.rs b/crates/common/src/config/mailstore/scripts.rs index 7a34e0f1..9cdb1fc7 100644 --- a/crates/common/src/config/mailstore/scripts.rs +++ b/crates/common/src/config/mailstore/scripts.rs @@ -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: sender@example.org\r\nTo: john@example.com\r\n{header}Subject: hi\r\n\r\nbody\r\n" + ); + let mut ctx = runtime + .filter(message.as_bytes()) + .with_envelope(sieve::Envelope::From, "sender@example.org") + .with_envelope(sieve::Envelope::To, "john@example.com") + .with_user_address("john@example.com"); + 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:john@example.net\";\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}" + ); + } + } } diff --git a/vendor/sieve-rs/VENDORED.md b/vendor/sieve-rs/VENDORED.md index 7e5c7d60..dcd353d6 100644 --- a/vendor/sieve-rs/VENDORED.md +++ b/vendor/sieve-rs/VENDORED.md @@ -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.) diff --git a/vendor/sieve-rs/src/runtime/actions/action_notify.rs b/vendor/sieve-rs/src/runtime/actions/action_notify.rs index 718fde02..2ef062e5 100644 --- a/vendor/sieve-rs/src/runtime/actions/action_notify.rs +++ b/vendor/sieve-rs/src/runtime/actions/action_notify.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * 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; } } diff --git a/vendor/sieve-rs/src/runtime/actions/action_vacation.rs b/vendor/sieve-rs/src/runtime/actions/action_vacation.rs index db757875..58ad8f15 100644 --- a/vendor/sieve-rs/src/runtime/actions/action_vacation.rs +++ b/vendor/sieve-rs/src/runtime/actions/action_vacation.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * 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(',') -- 2.54.0