Files
inbuxa-server/crates/smtp/src/core/mod.rs
T
jcoffey-dev 7f22006e97
ci / fork-checks (pull_request) Successful in 50s
ci / build (pull_request) Successful in 4m52s
Mail flow rules: carry out the transport actions
Phase 2g of the DLP and mail flow rules spec: transport rules now act,
on outgoing and incoming mail.

- features/mailflow/rewrite.rs: add or remove a header, prefix or set the
  subject (an RFC 2047 word when not ASCII), add a disclaimer. A
  disclaimer edits the message's main text and HTML bodies only, each
  decoded, changed and written back as UTF-8 quoted-printable with its
  other headers kept, top or bottom (after <body> or before </body> in
  HTML); attachments and attached messages are left alone, and a
  disclaimer already present isn't added again.
- smtp/inbound/mailflow.rs: the check runs for incoming mail too
  (transport rules only; DLP stays outgoing). After DLP passes, each
  matched transport rule's actions run in order: message edits,
  add-recipient and redirect (envelope changes DATA applies), route (a
  per-message queue ahead of the queue strategy), refuse (550 5.7.1
  with the rule's text). The override tag is stripped with the same
  subject writer, so a non-ASCII subject stays valid.
- Audit: refusals and changes to where mail goes are recorded (sender,
  or system:mail-flow for incoming mail); wording and header changes
  aren't, or a banner rule would record every message (spec §2.7).

Tests: rewrite unit tests (headers, encoded subjects, disclaimers on a
single part and on multipart/alternative with an attachment, once
only); mail_rules_tests gains the actions end to end: disclaimer,
header and subject prefix on a delivered message, a redirect, a
refusal, a banner on incoming LMTP mail that outgoing rules leave
alone, and which of those are audited.
2026-09-28 18:08:40 -07:00

344 lines
9.2 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{inbound::auth::SaslToken, queue::QueueId};
use common::{
Inner, Server,
auth::AccountInfo,
config::smtp::auth::VerifyStrategy,
network::{ServerInstance, asn::AsnGeoLookupResult},
};
use email::message::delivery::ORCPT_ADDR_TYPE;
use mail_auth::{IprevOutput, SpfOutput};
use smtp_proto::request::receiver::{
BdatReceiver, DataReceiver, DummyDataReceiver, DummyLineReceiver, LineReceiver, RequestReceiver,
};
use std::{
hash::Hash,
net::IpAddr,
sync::Arc,
time::{Duration, Instant},
};
use tokio::io::{AsyncRead, AsyncWrite};
use utils::DomainPart;
pub mod params;
pub mod throttle;
#[derive(Clone)]
pub struct SmtpSessionManager {
pub inner: Arc<Inner>,
}
impl SmtpSessionManager {
pub fn new(inner: Arc<Inner>) -> Self {
Self { inner }
}
}
pub enum State {
Request(RequestReceiver),
Bdat(BdatReceiver),
Data(DataReceiver),
Sasl(LineReceiver<SaslToken>),
SkipData(DummyDataReceiver, &'static [u8]),
RequestTooLarge(DummyLineReceiver),
Accepted(QueueId),
None,
}
pub struct Session<T: AsyncWrite + AsyncRead> {
pub hostname: String,
pub state: State,
pub instance: Arc<ServerInstance>,
pub server: Server,
pub stream: T,
pub data: SessionData,
pub params: SessionParameters,
}
pub struct SessionData {
pub session_id: u64,
pub local_ip: IpAddr,
pub local_ip_str: String,
pub local_port: u16,
pub remote_ip: IpAddr,
pub remote_ip_str: String,
pub remote_port: u16,
pub asn_geo_data: AsnGeoLookupResult,
pub helo_domain: String,
pub mail_from: Option<SessionAddress>,
pub rcpt_to: Vec<SessionAddress>,
pub rcpt_errors: usize,
pub rcpt_oks: usize,
pub message: Vec<u8>,
pub authenticated_as: Option<AccountInfo>,
pub auth_errors: usize,
pub priority: i16,
pub delivery_by: i64,
pub future_release: u64,
pub valid_until: Instant,
pub bytes_left: usize,
pub messages_sent: usize,
pub iprev: Option<IprevOutput>,
pub spf_ehlo: Option<SpfOutput>,
pub spf_mail_from: Option<SpfOutput>,
pub dnsbl_error: Option<Vec<u8>>,
// inbuxa: DLP (dlp-and-mail-flow-rules spec, §2.5): the reason a JMAP
// sender gave to send despite a warning, and why DATA refused a
// message, for the submission to report
pub dlp_override: Option<String>,
pub dlp_refusal: Option<DlpRefusal>,
// inbuxa: a mail flow rule's route for this message
pub mailflow_queue: Option<String>,
}
/// inbuxa: a DATA refusal by DLP rules: blocked, or a warning the sender
/// may override, with each rule's name and notice.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DlpRefusal {
pub blocked: bool,
pub rules: Vec<(String, String)>,
}
#[derive(Clone, Debug)]
pub struct SessionAddress {
pub address: String,
pub address_lcase: String,
pub domain: String,
pub flags: u64,
pub dsn_info: Option<String>,
}
#[derive(Debug, Default)]
pub struct SessionParameters {
// Global parameters
pub timeout: Duration,
// Ehlo parameters
pub ehlo_require: bool,
pub ehlo_reject_non_fqdn: bool,
// Auth parameters
pub auth_require: bool,
pub auth_errors_max: usize,
pub auth_errors_wait: Duration,
// Rcpt parameters
pub rcpt_errors_max: usize,
pub rcpt_errors_wait: Duration,
pub rcpt_max: usize,
pub rcpt_dsn: bool,
pub can_expn: bool,
pub can_vrfy: bool,
pub max_message_size: usize,
// Mail authentication parameters
pub iprev: VerifyStrategy,
pub spf_ehlo: VerifyStrategy,
pub spf_mail_from: VerifyStrategy,
}
impl SessionData {
pub fn new(
local_ip: IpAddr,
local_port: u16,
remote_ip: IpAddr,
remote_port: u16,
asn_geo_data: AsnGeoLookupResult,
session_id: u64,
) -> Self {
SessionData {
session_id,
local_ip,
local_port,
remote_ip,
local_ip_str: local_ip.to_string(),
remote_ip_str: remote_ip.to_string(),
remote_port,
asn_geo_data,
helo_domain: String::new(),
mail_from: None,
rcpt_to: Vec::new(),
authenticated_as: None,
priority: 0,
valid_until: Instant::now(),
rcpt_errors: 0,
rcpt_oks: 0,
message: Vec::with_capacity(0),
auth_errors: 0,
messages_sent: 0,
bytes_left: 0,
delivery_by: 0,
future_release: 0,
iprev: None,
spf_ehlo: None,
spf_mail_from: None,
dnsbl_error: None,
dlp_override: None,
dlp_refusal: None,
mailflow_queue: None,
}
}
}
impl Default for State {
fn default() -> Self {
State::Request(RequestReceiver::default())
}
}
impl PartialEq for SessionAddress {
fn eq(&self, other: &Self) -> bool {
self.address_lcase == other.address_lcase
}
}
impl Eq for SessionAddress {}
impl Hash for SessionAddress {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.address_lcase.hash(state);
}
}
impl Ord for SessionAddress {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
match self.domain.cmp(&other.domain) {
std::cmp::Ordering::Equal => self.address_lcase.cmp(&other.address_lcase),
order => order,
}
}
}
impl PartialOrd for SessionAddress {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Session<common::network::stream::NullIo> {
pub fn local(
server: Server,
instance: std::sync::Arc<ServerInstance>,
data: SessionData,
) -> Self {
Session {
hostname: "localhost".into(),
state: State::None,
instance,
server,
stream: common::network::stream::NullIo::default(),
data,
params: SessionParameters {
timeout: Default::default(),
ehlo_require: Default::default(),
ehlo_reject_non_fqdn: Default::default(),
auth_require: Default::default(),
auth_errors_max: Default::default(),
auth_errors_wait: Default::default(),
rcpt_errors_max: Default::default(),
rcpt_errors_wait: Default::default(),
rcpt_max: Default::default(),
rcpt_dsn: Default::default(),
max_message_size: Default::default(),
iprev: VerifyStrategy::Disable,
spf_ehlo: VerifyStrategy::Disable,
spf_mail_from: VerifyStrategy::Disable,
can_expn: false,
can_vrfy: false,
},
}
}
pub fn has_failed(&mut self) -> Option<String> {
if self.stream.tx_buf.first().is_none_or(|&c| c == b'2') {
self.stream.tx_buf.clear();
None
} else {
let response = std::str::from_utf8(&self.stream.tx_buf)
.unwrap()
.trim()
.into();
self.stream.tx_buf.clear();
Some(response)
}
}
}
impl SessionData {
pub fn local(
authenticated_as: AccountInfo,
mail_from: Option<SessionAddress>,
rcpt_to: Vec<SessionAddress>,
message: Vec<u8>,
session_id: u64,
) -> Self {
SessionData {
local_ip: IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)),
remote_ip: IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)),
local_ip_str: "127.0.0.1".into(),
remote_ip_str: "127.0.0.1".into(),
remote_port: 0,
local_port: 0,
session_id,
asn_geo_data: AsnGeoLookupResult::default(),
helo_domain: "localhost".into(),
mail_from,
rcpt_to,
rcpt_errors: 0,
rcpt_oks: 0,
message,
authenticated_as: Some(authenticated_as),
auth_errors: 0,
priority: 0,
delivery_by: 0,
future_release: 0,
valid_until: Instant::now(),
bytes_left: 0,
messages_sent: 0,
iprev: None,
spf_ehlo: None,
spf_mail_from: None,
dnsbl_error: None,
dlp_override: None,
dlp_refusal: None,
mailflow_queue: None,
}
}
}
impl SessionAddress {
pub fn new(address: String) -> Self {
let address_lcase = address.to_lowercase();
SessionAddress {
domain: address_lcase.domain_part().into(),
address_lcase,
address,
flags: 0,
dsn_info: None,
}
}
pub fn orig_address(&self) -> &str {
self.dsn_info.as_deref().unwrap_or(&self.address_lcase)
}
pub fn orcpt_parameter(&self) -> Option<String> {
self.dsn_info
.as_deref()
.map(|orcpt| format!("{ORCPT_ADDR_TYPE}{}", orcpt.to_lowercase()))
}
}