use crate::{
config::{
model::{EnhancedStatusCode, ExplainStringMod, HeaderType, ReasonExplainString, ReplyCode},
Config, SessionConfig,
},
header::{
auth_results::{self, AuthenticationResultsHeader},
received_spf::ReceivedSpfHeader,
HeaderField,
},
verify::{self, Identity, VerificationResult, Verifier},
};
use indymilter::{ActionError, ContextActions, SetErrorReply, SmtpReplyError, Status};
use log::{debug, info};
use std::{borrow::Cow, ffi::CString, net::IpAddr, sync::Arc};
use viaspf::{record::ExplainString, ExplanationString, SpfResult, SpfResultCause};
pub trait SpfResultKind {
fn kind(&self) -> &'static str;
}
impl SpfResultKind for SpfResult {
fn kind(&self) -> &'static str {
match self {
Self::None => "none",
Self::Neutral => "neutral",
Self::Pass => "pass",
Self::Fail(_) => "fail",
Self::Softfail => "softfail",
Self::Temperror => "temperror",
Self::Permerror => "permerror",
}
}
}
#[derive(Default)]
struct ConnectionData {
hostname: Option<String>,
ip: Option<IpAddr>,
helo_host: Option<String>,
helo_result: Option<VerificationResult>,
}
impl ConnectionData {
fn new() -> Self {
Default::default()
}
fn hostname(&self) -> &str {
self.hostname.as_deref().expect("no hostname available")
}
fn ip(&self) -> IpAddr {
self.ip.expect("no IP address available")
}
}
struct MessageData {
results: Vec<VerificationResult>,
auth_results_i: usize,
auth_results_deletions: Vec<usize>,
}
impl MessageData {
fn new(results: Vec<VerificationResult>) -> Self {
Self {
results,
auth_results_i: 0,
auth_results_deletions: Vec::new(),
}
}
}
pub struct AuthSession {
pub session_config: Arc<SessionConfig>,
conn: ConnectionData,
message: Option<MessageData>,
}
impl AuthSession {
pub fn new(session_config: Arc<SessionConfig>) -> Self {
Self {
session_config,
conn: ConnectionData::new(),
message: None,
}
}
pub fn init_connection(&mut self, hostname: impl Into<String>, ip: impl Into<IpAddr>) {
self.conn.hostname = Some(hostname.into());
self.conn.ip = Some(ip.into());
}
pub async fn authorize_helo(
&mut self,
reply: &mut impl SetErrorReply,
helo_host: impl Into<String>,
) -> Result<Status, SmtpReplyError> {
let config = &self.session_config.config;
self.conn.helo_host = Some(helo_host.into());
if config.verify_helo() {
let helo_host = self.conn.helo_host.as_ref().unwrap();
let prev_result = self.conn.helo_result.take();
if config.skip_senders().includes(helo_host) {
return skip_sender(helo_host);
}
let mut verifier = Verifier::new(
&self.session_config.resolver,
config,
self.conn.hostname(),
self.conn.ip(),
);
if let Some(result) = verifier.verify_helo(helo_host).await {
log_verification_result(&result, matches!(prev_result, Some(r) if r == result));
if config.reject_helo_results().includes(&result.spf_result) {
return reject_sender(reply, config, &result, &verifier).await;
}
self.conn.helo_result = Some(result);
} else {
debug!("not verifying non-FQDN HELO identity \"{helo_host}\"");
}
}
Ok(Status::Continue)
}
pub async fn authorize_mail_from(
&mut self,
reply: &mut impl SetErrorReply,
mail_from: &str,
) -> Result<Status, SmtpReplyError> {
let config = &self.session_config.config;
let mut results = Vec::new();
if let Some(result) = &self.conn.helo_result {
if config.definitive_helo_results().includes(&result.spf_result) {
results.push(result.clone());
self.message = Some(MessageData::new(results));
return Ok(Status::Continue);
}
if config.include_all_results() {
results.push(result.clone());
}
}
let helo_host = self.conn.helo_host.as_deref();
let mail_from = verify::prepare_mail_from_identity(mail_from, helo_host);
if config.skip_senders().includes(&mail_from) {
self.message = Some(MessageData::new(results));
return skip_sender(&mail_from);
}
let mut verifier = Verifier::new(
&self.session_config.resolver,
config,
self.conn.hostname(),
self.conn.ip(),
);
let result = verifier.verify_mail_from(&mail_from, helo_host).await;
log_verification_result(&result, false);
if config.reject_results().includes(&result.spf_result) {
return reject_sender(reply, config, &result, &verifier).await;
}
results.push(result);
self.message = Some(MessageData::new(results));
Ok(Status::Continue)
}
pub fn process_auth_results_header(&mut self, id: &str, value: &str) {
let config = &self.session_config.config;
if config.delete_incoming_authentication_results() {
let message = self
.message
.as_mut()
.expect("authorization session message data not available");
message.auth_results_i += 1;
if let Some(incoming_aid) = auth_results::extract_authserv_id(value) {
let aid = authserv_id(config, self.conn.hostname());
if eq_authserv_ids(aid, &incoming_aid) {
debug!(
"{id}: recognized own authserv-id in incoming Authentication-Results header instance {}",
message.auth_results_i
);
message.auth_results_deletions.push(message.auth_results_i);
}
} else {
debug!(
"{id}: failed to parse incoming Authentication-Results header instance {}",
message.auth_results_i
);
}
}
}
pub async fn finish_message(
&mut self,
actions: &impl ContextActions,
id: &str,
) -> Result<Status, ActionError> {
let config = &self.session_config.config;
let message = self
.message
.take()
.expect("authorization session message data not available");
if config.delete_incoming_authentication_results() {
delete_auth_results_headers(actions, config, id, message.auth_results_deletions)
.await?;
}
add_headers(
actions,
config,
id,
self.conn.hostname(),
self.conn.ip(),
self.conn.helo_host.as_deref(),
message.results,
)
.await?;
Ok(Status::Continue)
}
pub fn abort_message(&mut self) {
self.message = None;
}
}
fn skip_sender(identity: &str) -> Result<Status, SmtpReplyError> {
debug!("not verifying exempt sender \"{identity}\"");
Ok(Status::Continue)
}
fn log_verification_result(result: &VerificationResult, repeated: bool) {
let VerificationResult { identity, spf_result, cause } = result;
let log_result = |args| {
if let Some(SpfResultCause::Error(cause)) = cause {
if repeated {
debug!("{args} ({cause}; repeated verification result)");
} else {
info!("{args} ({cause})");
}
} else if repeated {
debug!("{args} (repeated verification result)");
} else {
info!("{args}");
}
};
log_result(format_args!("{} ({}): {}", identity, identity.name(), spf_result.kind()));
}
async fn reject_sender(
reply: &mut impl SetErrorReply,
config: &Config,
result: &VerificationResult,
verifier: &Verifier<'_>,
) -> Result<Status, SmtpReplyError> {
let VerificationResult { identity, spf_result, cause } = result;
let scope = match identity {
Identity::Helo(_) => "connection",
Identity::MailFrom(_) => "message",
};
if config.dry_run() {
debug!("rejected {scope} from sender \"{identity}\" [dry run, not done]");
Ok(Status::Accept)
} else {
let (reply_code, status_code, reply_text) =
make_reply_params(config, spf_result, cause.as_ref(), verifier).await;
let reply_text = escape_reply_text(&reply_text);
reply.set_error_reply(
reply_code.as_ref(),
Some(status_code.as_ref()),
[reply_text.as_ref()], )?;
debug!("rejected {scope} from sender \"{identity}\"");
Ok(match reply_code {
ReplyCode::Transient(_) => Status::Tempfail,
ReplyCode::Permanent(_) => Status::Reject,
})
}
}
async fn make_reply_params<'a, 'b>(
config: &'a Config,
spf_result: &'b SpfResult,
cause: Option<&SpfResultCause>,
verifier: &Verifier<'_>,
) -> (&'a ReplyCode, &'a EnhancedStatusCode, Cow<'b, str>) {
let es;
let (reply_code, status_code, reply_text) = match spf_result {
SpfResult::Fail(ExplanationString::External(e)) => {
return (
config.fail_reply_code(),
config.fail_status_code(),
e.into(),
);
}
SpfResult::Fail(ExplanationString::Default) => (
config.fail_reply_code(),
config.fail_status_code(),
config.fail_reply_text(),
),
SpfResult::Softfail => (
config.softfail_reply_code(),
config.softfail_status_code(),
config.softfail_reply_text(),
),
SpfResult::Temperror => {
es = make_error_explain_string(config.temperror_reply_text(), cause);
(
config.temperror_reply_code(),
config.temperror_status_code(),
es.as_ref(),
)
}
SpfResult::Permerror => {
es = make_error_explain_string(config.permerror_reply_text(), cause);
(
config.permerror_reply_code(),
config.permerror_status_code(),
es.as_ref(),
)
}
_ => panic!("rejection of SPF result \"{spf_result}\" not supported"),
};
let reply_text = verifier.expand_explain_string(reply_text).await;
(reply_code, status_code, reply_text.into())
}
fn make_error_explain_string<'a>(
es: &'a ReasonExplainString,
cause: Option<&SpfResultCause>,
) -> Cow<'a, ExplainString> {
match es.as_ref() {
ExplainStringMod::Substitute(explain_string) => Cow::Borrowed(explain_string),
ExplainStringMod::Decorate { prefix, suffix } => {
let mut es = match cause {
Some(SpfResultCause::Error(cause)) => cause
.to_string()
.parse::<ExplainString>()
.expect("error result cause not an explain-string"),
_ => Default::default(),
};
es.segments.splice(..0, prefix.segments.iter().cloned());
es.segments.extend(suffix.segments.iter().cloned());
Cow::Owned(es)
}
}
}
fn escape_reply_text(s: &str) -> Cow<'_, str> {
fn is_regular_char(c: char) -> bool {
c.is_ascii_graphic() && !matches!(c, '%') || matches!(c, ' ' | '\t')
}
if s.chars().all(is_regular_char) {
s.into()
} else {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
if is_regular_char(c) {
result.push(c);
} else if c == '%' {
result.push_str("%%");
} else {
result.extend(c.escape_default());
}
}
result.into()
}
}
fn eq_authserv_ids(id1: &str, id2: &str) -> bool {
fn to_unicode(s: &str) -> String {
let (result, e) = idna::domain_to_unicode(s);
if e.is_err() {
debug!("validation error while converting domain \"{s}\" to Unicode");
}
result
}
to_unicode(id1) == to_unicode(id2)
}
async fn delete_auth_results_headers(
actions: &impl ContextActions,
config: &Config,
id: &str,
deletions: Vec<usize>,
) -> Result<(), ActionError> {
for i in deletions.into_iter().rev() {
if config.dry_run() {
debug!(
"{id}: deleting incoming Authentication-Results header instance {i} [dry run, not done]",
);
} else {
debug!("{id}: deleting incoming Authentication-Results header instance {i}");
actions
.change_header(AuthenticationResultsHeader::NAME, i as _, None::<CString>)
.await?;
}
}
Ok(())
}
async fn add_headers(
actions: &impl ContextActions,
config: &Config,
id: &str,
hostname: &str,
ip: IpAddr,
helo_host: Option<&str>,
results: Vec<VerificationResult>,
) -> Result<(), ActionError> {
for header_type in config.header().iter() {
match header_type {
HeaderType::ReceivedSpf => {
for result in &results {
let header = ReceivedSpfHeader::new(result, hostname, ip, helo_host);
insert_header_field(actions, config, id, header).await?;
}
}
HeaderType::AuthenticationResults => {
if !results.is_empty() {
let authserv_id = authserv_id(config, hostname);
let header = AuthenticationResultsHeader::new(
authserv_id,
&results,
config.include_mailfrom_local_part(),
);
insert_header_field(actions, config, id, header).await?;
}
}
}
}
Ok(())
}
async fn insert_header_field(
actions: &impl ContextActions,
config: &Config,
id: &str,
header_field: impl HeaderField,
) -> Result<(), ActionError> {
let header_name = header_field.name();
if config.dry_run() {
debug!("{id}: adding {header_name} header [dry run, not done]");
debug!("{id}: {header_field}");
} else {
debug!("{id}: adding {header_name} header");
debug!("{id}: {header_field}");
actions
.insert_header(0, header_name, header_field.format_body())
.await?;
}
Ok(())
}
fn authserv_id<'a>(config: &'a Config, hostname: &'a str) -> &'a str {
config.authserv_id().unwrap_or(hostname)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::model::{DefinitiveHeloResultKind, RejectResultKind, SkipEntry, Socket};
use async_trait::async_trait;
use indymilter::IntoCString;
use once_cell::sync::Lazy;
use std::{
collections::HashSet,
net::{Ipv4Addr, Ipv6Addr},
sync::Mutex,
};
use viaspf::{
lookup::{Lookup, LookupError, LookupResult, Name},
DomainName,
};
#[test]
fn escape_reply_text_ok() {
assert_eq!(escape_reply_text("a b%20c"), "a b%%20c");
assert_eq!(escape_reply_text("\r"), "\\r");
assert_eq!(escape_reply_text("\x08🟥"), "\\u{8}\\u{1f7e5}");
}
#[test]
fn eq_authserv_ids_ok() {
assert!(eq_authserv_ids("example.org", "eXaMpLe.OrG"));
assert!(eq_authserv_ids("ÖBB.at", "xn--bb-eka.at"));
assert!(!eq_authserv_ids("oebb.at", "xn--bb-eka.at"));
}
#[derive(Default)]
struct MockLookup {
lookup_a: Option<Box<dyn Fn(&Name) -> LookupResult<Vec<Ipv4Addr>> + Send + Sync + 'static>>,
lookup_aaaa: Option<Box<dyn Fn(&Name) -> LookupResult<Vec<Ipv6Addr>> + Send + Sync + 'static>>,
lookup_mx: Option<Box<dyn Fn(&Name) -> LookupResult<Vec<Name>> + Send + Sync + 'static>>,
lookup_txt: Option<Box<dyn Fn(&Name) -> LookupResult<Vec<String>> + Send + Sync + 'static>>,
lookup_ptr: Option<Box<dyn Fn(IpAddr) -> LookupResult<Vec<Name>> + Send + Sync + 'static>>,
}
#[async_trait]
impl Lookup for MockLookup {
async fn lookup_a(&self, name: &Name) -> LookupResult<Vec<Ipv4Addr>> {
self.lookup_a
.as_ref()
.map_or(Err(LookupError::NoRecords), |f| f(name))
}
async fn lookup_aaaa(&self, name: &Name) -> LookupResult<Vec<Ipv6Addr>> {
self.lookup_aaaa
.as_ref()
.map_or(Err(LookupError::NoRecords), |f| f(name))
}
async fn lookup_mx(&self, name: &Name) -> LookupResult<Vec<Name>> {
self.lookup_mx
.as_ref()
.map_or(Err(LookupError::NoRecords), |f| f(name))
}
async fn lookup_txt(&self, name: &Name) -> LookupResult<Vec<String>> {
self.lookup_txt
.as_ref()
.map_or(Err(LookupError::NoRecords), |f| f(name))
}
async fn lookup_ptr(&self, ip: IpAddr) -> LookupResult<Vec<Name>> {
self.lookup_ptr
.as_ref()
.map_or(Err(LookupError::NoRecords), |f| f(ip))
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum Action {
InsertHeader(String, String),
DeleteHeader(String, i32),
}
#[derive(Default)]
struct MockEomActions {
executed: Mutex<Vec<Action>>,
}
impl MockEomActions {
fn new() -> Self {
Default::default()
}
}
#[async_trait]
impl ContextActions for MockEomActions {
async fn insert_header<'cx, 'k, 'v>(
&'cx self,
index: i32,
name: impl IntoCString + Send + 'k,
value: impl IntoCString + Send + 'v,
) -> Result<(), ActionError> {
assert_eq!(index, 0);
let action = Action::InsertHeader(
name.into_c_string().into_string().unwrap(),
value.into_c_string().into_string().unwrap(),
);
self.executed.lock().unwrap().push(action);
Ok(())
}
async fn change_header<'cx, 'k, 'v>(
&'cx self,
name: impl IntoCString + Send + 'k,
index: i32,
value: Option<impl IntoCString + Send + 'v>,
) -> Result<(), ActionError> {
assert_eq!(value.map(|v| v.into_c_string()), None);
let action = Action::DeleteHeader(name.into_c_string().into_string().unwrap(), index);
self.executed.lock().unwrap().push(action);
Ok(())
}
async fn add_header<'cx, 'k, 'v>(
&'cx self,
_: impl IntoCString + Send + 'k,
_: impl IntoCString + Send + 'v,
) -> Result<(), ActionError> {
unimplemented!()
}
async fn change_sender<'cx, 'a, 'b>(
&'cx self,
_: impl IntoCString + Send + 'a,
_: Option<impl IntoCString + Send + 'b>,
) -> Result<(), ActionError> {
unimplemented!()
}
async fn replace_body<'cx, 'a>(&'cx self, _: &'a [u8]) -> Result<(), ActionError> {
unimplemented!()
}
async fn add_recipient<'cx, 'a>(
&'cx self,
_: impl IntoCString + Send + 'a,
) -> Result<(), ActionError> {
unimplemented!()
}
async fn add_recipient_ext<'cx, 'a, 'b>(
&'cx self,
_: impl IntoCString + Send + 'a,
_: Option<impl IntoCString + Send + 'b>,
) -> Result<(), ActionError> {
unimplemented!()
}
async fn delete_recipient<'cx, 'a>(
&'cx self,
_: impl IntoCString + Send + 'a,
) -> Result<(), ActionError> {
unimplemented!()
}
async fn progress<'cx>(&'cx self) -> Result<(), ActionError> {
unimplemented!()
}
async fn quarantine<'cx, 'a>(
&'cx self,
_: impl IntoCString + Send + 'a,
) -> Result<(), ActionError> {
unimplemented!()
}
}
#[derive(Default)]
struct MockSmtpReply {
error_reply: Option<(String, Option<String>, Vec<CString>)>,
}
impl MockSmtpReply {
fn new() -> Self {
Default::default()
}
}
impl SetErrorReply for MockSmtpReply {
fn set_error_reply<I, T>(
&mut self,
rcode: &str,
xcode: Option<&str>,
message: I,
) -> Result<(), SmtpReplyError>
where
I: IntoIterator<Item = T>,
T: IntoCString,
{
self.error_reply = Some((
rcode.into(),
xcode.map(|c| c.into()),
message.into_iter().map(|l| l.into_c_string()).collect(),
));
Ok(())
}
}
fn error_reply(
rcode: &str,
xcode: &str,
message: &str,
) -> (String, Option<String>, Vec<CString>) {
(
rcode.into(),
Some(xcode.into()),
vec![CString::new(message).unwrap()],
)
}
const ID: &str = "NONE";
static SOCKET: Lazy<Socket> = Lazy::new(|| "unix:unused".parse().unwrap());
#[tokio::test]
async fn reject_unauthorized_sender() {
let config = Config::builder(SOCKET.clone())
.verify_helo(false)
.reject_results(HashSet::from([RejectResultKind::Softfail]))
.softfail_reply_code("551".parse().unwrap())
.softfail_status_code("5.1.1".parse().unwrap())
.softfail_reply_text("not authorized".parse().unwrap())
.build()
.unwrap();
let resolver = Box::new(MockLookup {
lookup_txt: Some(Box::new(|name| match name.as_str() {
"example.com." => Ok(vec!["v=spf1 ~all".into()]),
_ => panic!(),
})),
..Default::default()
});
let config = SessionConfig::with_mock_resolver(config, resolver);
let mut session = AuthSession::new(config.into());
session.init_connection("mail.example.org", [1, 2, 3, 4]);
let mut reply = MockSmtpReply::new();
let status = session.authorize_helo(&mut reply, "mail.example.com").await;
assert_eq!(status, Ok(Status::Continue));
let status = session.authorize_mail_from(&mut reply, "me@example.com").await;
assert_eq!(status, Ok(Status::Reject));
assert_eq!(
reply.error_reply,
Some(error_reply("551", "5.1.1", "not authorized"))
);
}
#[tokio::test]
async fn reject_null_sender_with_invalid_helo_record() {
let config = Config::builder(SOCKET.clone())
.reject_helo_results(HashSet::new())
.permerror_reply_text(ExplainStringMod::Decorate {
prefix: "SPF error: ".parse().unwrap(),
suffix: Default::default(),
})
.build()
.unwrap();
let resolver = Box::new(MockLookup {
lookup_txt: Some(Box::new(|name| match name.as_str() {
"mail.example.com." => Ok(vec!["v=spf1 invalid record".into()]),
_ => panic!(),
})),
..Default::default()
});
let config = SessionConfig::with_mock_resolver(config, resolver);
let mut session = AuthSession::new(config.into());
session.init_connection("mail.example.org", [1, 2, 3, 4]);
let mut reply = MockSmtpReply::new();
let status = session.authorize_helo(&mut reply, "mail.example.com").await;
assert_eq!(status, Ok(Status::Continue));
let status = session.authorize_mail_from(&mut reply, "<>").await;
assert_eq!(status, Ok(Status::Reject));
assert_eq!(
reply.error_reply,
Some(error_reply("550", "5.7.24", "SPF error: invalid SPF record found"))
);
}
#[tokio::test]
async fn reject_helo_timeout_with_escaped_reply_text() {
let config = Config::builder(SOCKET.clone())
.temperror_reply_code("441".parse().unwrap())
.temperror_status_code("4.1.1".parse().unwrap())
.temperror_reply_text(ExplainStringMod::Substitute(
"reply%_with%-escaping".parse().unwrap(),
))
.build()
.unwrap();
let resolver = Box::new(MockLookup {
lookup_txt: Some(Box::new(|name| match name.as_str() {
"mail.example.com." => Err(LookupError::Timeout),
_ => panic!(),
})),
..Default::default()
});
let config = SessionConfig::with_mock_resolver(config, resolver);
let mut session = AuthSession::new(config.into());
session.init_connection("mail.example.org", [1, 2, 3, 4]);
let mut reply = MockSmtpReply::new();
let status = session.authorize_helo(&mut reply, "mail.example.com").await;
assert_eq!(status, Ok(Status::Tempfail));
assert_eq!(
reply.error_reply,
Some(error_reply("441", "4.1.1", "reply with%%20escaping"))
);
}
#[tokio::test]
async fn reject_failure_with_i18n_explanation_from_dns() {
let config = Config::builder(SOCKET.clone())
.verify_helo(false)
.build()
.unwrap();
let resolver = Box::new(MockLookup {
lookup_txt: Some(Box::new(|name| match name.as_str() {
"example.com." => Ok(vec!["v=spf1 redirect=explainer.org".into()]),
"explainer.org." => Ok(vec!["v=spf1 -all exp=exp._spf.%{d}".into()]),
"exp._spf.explainer.org." => Ok(vec!["You, %{l}, are 100%% a fraud!".into()]),
_ => panic!(),
})),
..Default::default()
});
let config = SessionConfig::with_mock_resolver(config, resolver);
let mut session = AuthSession::new(config.into());
session.init_connection("mail.example.org", [1, 2, 3, 4]);
let mut reply = MockSmtpReply::new();
let status = session.authorize_helo(&mut reply, "mail.example.com").await;
assert_eq!(status, Ok(Status::Continue));
let status = session.authorize_mail_from(&mut reply, "敦文@example.com").await;
assert_eq!(status, Ok(Status::Reject));
assert_eq!(
reply.error_reply,
Some(error_reply(
"550",
"5.7.23",
"SPF validation failed: example.com explains: \
You, \\u{6566}\\u{6587}, are 100%% a fraud!",
))
);
}
#[tokio::test]
async fn delete_forged_authentication_results_headers() {
let config = Config::builder(SOCKET.clone())
.delete_incoming_authentication_results(true)
.build()
.unwrap();
let resolver = Box::new(MockLookup::default());
let config = SessionConfig::with_mock_resolver(config, resolver);
let mut session = AuthSession::new(config.into());
session.init_connection("mail.example.org", [1, 2, 3, 4]);
let mut reply = MockSmtpReply::new();
let status = session.authorize_helo(&mut reply, "mail.example.com").await;
assert_eq!(status, Ok(Status::Continue));
let status = session.authorize_mail_from(&mut reply, "me@example.com").await;
assert_eq!(status, Ok(Status::Continue));
session.process_auth_results_header(ID, "mail.example.org; spf=pass");
session.process_auth_results_header(ID, "example.org; spf=neutral");
session.process_auth_results_header(ID, "\"mail.EXAMPLE.org\"; spf=pass");
let actions = MockEomActions::new();
let status = session.finish_message(&actions, ID).await;
assert_eq!(status.unwrap(), Status::Continue);
let actions = actions.executed.lock().unwrap();
let actions = actions
.iter()
.cloned()
.take_while(|a| matches!(a, Action::DeleteHeader(..)))
.collect::<Vec<_>>();
assert_eq!(
actions,
[
Action::DeleteHeader("Authentication-Results".into(), 3),
Action::DeleteHeader("Authentication-Results".into(), 1),
]
);
}
#[tokio::test]
async fn add_header_default_no_spf() {
let config = Config::builder(SOCKET.clone()).build().unwrap();
let resolver = Box::new(MockLookup::default());
let config = SessionConfig::with_mock_resolver(config, resolver);
let mut session = AuthSession::new(config.into());
session.init_connection("mail.example.org", [1, 2, 3, 4]);
let mut reply = MockSmtpReply::new();
let status = session.authorize_helo(&mut reply, "mail.example.com").await;
assert_eq!(status, Ok(Status::Continue));
let status = session.authorize_mail_from(&mut reply, "me@example.com").await;
assert_eq!(status, Ok(Status::Continue));
let actions = MockEomActions::new();
let status = session.finish_message(&actions, ID).await;
assert_eq!(status.unwrap(), Status::Continue);
let actions = actions.executed.lock().unwrap();
assert_eq!(
actions.as_ref(),
[
Action::InsertHeader(
"Received-SPF".into(),
"none (mail.example.org: no authorization information available\n\
\tfor sender me@example.com) receiver=mail.example.org; client-ip=1.2.3.4;\n\
\thelo=mail.example.com; envelope-from=\"me@example.com\"; identity=mailfrom".into()
),
]
);
}
#[tokio::test]
async fn add_header_for_definitive_helo_result() {
let config = Config::builder(SOCKET.clone())
.verify_helo(true)
.definitive_helo_results(HashSet::from([DefinitiveHeloResultKind::Pass]))
.header(HeaderType::AuthenticationResults)
.build()
.unwrap();
let resolver = Box::new(MockLookup {
lookup_txt: Some(Box::new(|name| match name.as_str() {
"mail.example.com." => Ok(vec!["v=spf1 +all".into()]),
_ => panic!(),
})),
..Default::default()
});
let config = SessionConfig::with_mock_resolver(config, resolver);
let mut session = AuthSession::new(config.into());
session.init_connection("mail.example.org", [1, 2, 3, 4]);
let mut reply = MockSmtpReply::new();
let status = session.authorize_helo(&mut reply, "mail.example.com").await;
assert_eq!(status, Ok(Status::Continue));
let status = session.authorize_mail_from(&mut reply, "me@example.com").await;
assert_eq!(status, Ok(Status::Continue));
let actions = MockEomActions::new();
let status = session.finish_message(&actions, ID).await;
assert_eq!(status.unwrap(), Status::Continue);
let actions = actions.executed.lock().unwrap();
assert_eq!(
actions.as_ref(),
[
Action::InsertHeader(
"Authentication-Results".into(),
"mail.example.org; spf=pass smtp.helo=mail.example.com".into(),
),
]
);
}
#[tokio::test]
async fn add_header_with_unusable_helo_identity() {
let config = Config::builder(SOCKET.clone())
.verify_helo(true)
.build()
.unwrap();
let resolver = Box::new(MockLookup {
lookup_txt: Some(Box::new(|name| match name.as_str() {
"example.com." => Ok(vec!["v=spf1 +all".into()]),
_ => panic!(),
})),
..Default::default()
});
let config = SessionConfig::with_mock_resolver(config, resolver);
let mut session = AuthSession::new(config.into());
session.init_connection("mail.example.org", [1, 2, 3, 4]);
let mut reply = MockSmtpReply::new();
let status = session.authorize_helo(&mut reply, "[1.2.3.4]").await;
assert_eq!(status, Ok(Status::Continue));
let status = session.authorize_mail_from(&mut reply, "me@example.com").await;
assert_eq!(status, Ok(Status::Continue));
let actions = MockEomActions::new();
let status = session.finish_message(&actions, ID).await;
assert_eq!(status.unwrap(), Status::Continue);
let actions = actions.executed.lock().unwrap();
assert_eq!(
actions.as_ref(),
[
Action::InsertHeader(
"Received-SPF".into(),
"pass (mail.example.org: domain of me@example.com has authorized\n\
\thost 1.2.3.4) receiver=mail.example.org; client-ip=1.2.3.4; helo=\"[1.2.3.4]\";\n\
\tenvelope-from=\"me@example.com\"; identity=mailfrom; mechanism=all".into()
),
]
);
}
#[tokio::test]
async fn add_headers_for_all_results() {
let config = Config::builder(SOCKET.clone())
.header(vec![
HeaderType::AuthenticationResults,
HeaderType::ReceivedSpf,
])
.include_all_results(true)
.build()
.unwrap();
let resolver = Box::new(MockLookup {
lookup_txt: Some(Box::new(|_| Ok(vec!["v=spf1 +all".into()]))),
..Default::default()
});
let config = SessionConfig::with_mock_resolver(config, resolver);
let mut session = AuthSession::new(config.into());
session.init_connection("mail.example.org", [1, 2, 3, 4]);
let mut reply = MockSmtpReply::new();
let status = session.authorize_helo(&mut reply, "mail.example.com").await;
assert_eq!(status, Ok(Status::Continue));
let status = session.authorize_mail_from(&mut reply, "me@example.com").await;
assert_eq!(status, Ok(Status::Continue));
let actions = MockEomActions::new();
let status = session.finish_message(&actions, ID).await;
assert_eq!(status.unwrap(), Status::Continue);
let actions = actions.executed.lock().unwrap();
assert_eq!(
actions.as_ref(),
[
Action::InsertHeader(
"Authentication-Results".into(),
"mail.example.org;\n\
\tspf=pass smtp.helo=mail.example.com;\n\
\tspf=pass smtp.mailfrom=example.com".into()
),
Action::InsertHeader(
"Received-SPF".into(),
"pass (mail.example.org: domain mail.example.com has authorized\n\
\thost 1.2.3.4) receiver=mail.example.org; client-ip=1.2.3.4;\n\
\thelo=mail.example.com; identity=helo; mechanism=all".into()
),
Action::InsertHeader(
"Received-SPF".into(),
"pass (mail.example.org: domain of me@example.com has authorized\n\
\thost 1.2.3.4) receiver=mail.example.org; client-ip=1.2.3.4;\n\
\thelo=mail.example.com; envelope-from=\"me@example.com\"; identity=mailfrom;\n\
\tmechanism=all".into()
),
]
);
}
#[tokio::test]
async fn skip_sender_matching_mailfrom_identity() {
let config = Config::builder(SOCKET.clone())
.verify_helo(false)
.skip_senders(HashSet::from([SkipEntry {
local_part: None,
domain: DomainName::new("EXAMPLE.COM").unwrap(),
match_subdomains: false,
}]))
.build()
.unwrap();
let resolver = Box::new(MockLookup {
lookup_txt: Some(Box::new(|_| panic!())),
..Default::default()
});
let config = SessionConfig::with_mock_resolver(config, resolver);
let mut session = AuthSession::new(config.into());
session.init_connection("mail.example.org", [1, 2, 3, 4]);
let mut reply = MockSmtpReply::new();
let status = session.authorize_helo(&mut reply, "mail.example.com").await;
assert_eq!(status, Ok(Status::Continue));
let status = session.authorize_mail_from(&mut reply, "me@example.com").await;
assert_eq!(status, Ok(Status::Continue));
let actions = MockEomActions::new();
let status = session.finish_message(&actions, ID).await;
assert_eq!(status.unwrap(), Status::Continue);
let actions = actions.executed.lock().unwrap();
assert_eq!(actions.as_ref(), []);
}
#[tokio::test]
async fn dry_run_accepts_message() {
let config = Config::builder(SOCKET.clone())
.dry_run(true)
.build()
.unwrap();
let resolver = Box::new(MockLookup {
lookup_txt: Some(Box::new(|_| Ok(vec!["v=spf1 -all".into()]))),
..Default::default()
});
let config = SessionConfig::with_mock_resolver(config, resolver);
let mut session = AuthSession::new(config.into());
session.init_connection("mail.example.org", [1, 2, 3, 4]);
let mut reply = MockSmtpReply::new();
let status = session.authorize_helo(&mut reply, "mail.example.com").await;
assert_eq!(status, Ok(Status::Accept));
assert_eq!(reply.error_reply, None);
}
}