Import upstream v0.16.22, stripped

Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f
Enterprise-only files removed or emptied: 63
Enterprise-only snippets removed: 117 in 50 files
Dangling module declarations removed: 5
Cargo edits turning enterprise off: 14
Verification: clean
Enterprise feature gates left for rebuilt features: 19 in 18 files

Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
2026-09-18 10:21:56 -07:00
commit 7dae9b29fd
1650 changed files with 485521 additions and 0 deletions
+412
View File
@@ -0,0 +1,412 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use ahash::AHashSet;
use common::{Server, psl};
use mail_auth::{
flate2::read::GzDecoder,
report::{Feedback, Report, tlsrpt::TlsReport},
zip,
};
use mail_parser::{Message, MessagePart, MimeHeaders, PartType};
use registry::{
schema::structs::{ArfExternalReport, DmarcExternalReport, TlsExternalReport},
types::datetime::UTCDateTime,
};
use std::{
borrow::Cow,
io::{Cursor, Read},
};
use store::write::{BatchBuilder, now};
use trc::IncomingReportEvent;
use types::id::Id;
use crate::reporting::{inbound::LogReport, index::ExternalReportIndex};
enum Compression {
None,
Gzip,
Zip,
}
enum Format<D, T, A> {
Dmarc(D),
Tls(T),
Arf(A),
}
pub(crate) struct ReportData<'x> {
compression: Compression,
format: Format<(), (), ()>,
data: &'x [u8],
}
impl<'x> ReportData<'x> {
fn from_part(part: &'x MessagePart<'x>) -> Option<Self> {
match &part.body {
PartType::Text(report) => {
if part
.content_type()
.and_then(|ct| ct.subtype())
.is_some_and(|t| t.eq_ignore_ascii_case("xml"))
|| part
.attachment_name()
.and_then(|n| n.rsplit_once('.'))
.is_some_and(|(_, e)| e.eq_ignore_ascii_case("xml"))
{
Some(ReportData {
compression: Compression::None,
format: Format::Dmarc(()),
data: report.as_bytes(),
})
} else if part.is_content_type("message", "feedback-report") {
Some(ReportData {
compression: Compression::None,
format: Format::Arf(()),
data: report.as_bytes(),
})
} else {
None
}
}
PartType::Binary(report) | PartType::InlineBinary(report) => {
if part.is_content_type("message", "feedback-report") {
return Some(ReportData {
compression: Compression::None,
format: Format::Arf(()),
data: report.as_ref(),
});
}
let subtype = part
.content_type()
.and_then(|ct| ct.subtype())
.unwrap_or("");
let attachment_name = part.attachment_name();
let ext = attachment_name
.and_then(|f| f.rsplit_once('.'))
.map_or("", |(_, e)| e);
let tls_parts = subtype.rsplit_once('+');
let compression = match (tls_parts.map(|(_, c)| c).unwrap_or(subtype), ext) {
("gzip", _) => Compression::Gzip,
("zip", _) => Compression::Zip,
(_, "gz") => Compression::Gzip,
(_, "zip") => Compression::Zip,
_ => Compression::None,
};
let format = match (tls_parts.map(|(c, _)| c).unwrap_or(subtype), ext) {
("xml", _) => Format::Dmarc(()),
("tlsrpt", _) | (_, "json") => Format::Tls(()),
_ => {
if attachment_name.is_some_and(|n| n.contains(".xml") || n.contains('!')) {
Format::Dmarc(())
} else {
return None;
}
}
};
Some(ReportData {
compression,
format,
data: report.as_ref(),
})
}
_ => None,
}
}
fn extract(message: &'x Message<'x>) -> Vec<Self> {
message.parts.iter().filter_map(Self::from_part).collect()
}
pub(crate) fn is_present(message: &Message<'_>) -> bool {
message
.parts
.iter()
.any(|part| ReportData::from_part(part).is_some())
}
}
pub trait AnalyzeReport: Sync + Send {
fn analyze_report(&self, message: Message<'static>, session_id: u64);
}
impl AnalyzeReport for Server {
fn analyze_report(&self, message: Message<'static>, session_id: u64) {
let core = self.clone();
tokio::spawn(async move {
let from: String = message
.from()
.and_then(|a| a.last())
.and_then(|a| a.address())
.unwrap_or_default()
.into();
let to: Vec<String> = message.to().map_or_else(Vec::new, |a| {
a.iter()
.filter_map(|a| a.address())
.map(|a| a.into())
.collect()
});
let subject: String = message.subject().unwrap_or_default().into();
let reports = ReportData::extract(&message);
let max_size = core.core.smtp.report.analysis.max_size;
for report in reports {
let data = match report.compression {
Compression::None => Cow::Borrowed(report.data),
Compression::Gzip => {
match read_capped(GzDecoder::new(report.data), 0, max_size) {
Ok(buf) => Cow::Owned(buf),
Err(err) => {
trc::event!(
IncomingReport(IncomingReportEvent::DecompressError),
SpanId = session_id,
From = from.to_string(),
Reason = err.to_string(),
CausedBy = trc::location!()
);
continue;
}
}
}
Compression::Zip => {
let data = report.data.to_vec();
let result = tokio::task::spawn_blocking(
move || -> Result<Vec<u8>, std::io::Error> {
let mut archive = zip::ZipArchive::new(Cursor::new(data))
.map_err(std::io::Error::other)?;
if archive.is_empty() {
return Ok(Vec::new());
}
let mut file =
archive.by_index(0).map_err(std::io::Error::other)?;
let size_hint = file.size();
read_capped(&mut file, size_hint, max_size)
},
)
.await;
match result {
Ok(Ok(buf)) => Cow::Owned(buf),
Ok(Err(err)) => {
trc::event!(
IncomingReport(IncomingReportEvent::DecompressError),
SpanId = session_id,
From = from.to_string(),
Reason = err.to_string(),
CausedBy = trc::location!()
);
continue;
}
Err(err) => {
trc::event!(
IncomingReport(IncomingReportEvent::DecompressError),
SpanId = session_id,
From = from.to_string(),
Reason = err.to_string(),
CausedBy = trc::location!()
);
continue;
}
}
}
};
let report = match report.format {
Format::Dmarc(_) => match Report::parse_xml(&data) {
Ok(report) => {
// Log
report.log();
Format::Dmarc(report)
}
Err(err) => {
trc::event!(
IncomingReport(IncomingReportEvent::DmarcParseFailed),
SpanId = session_id,
From = from.to_string(),
Reason = err,
CausedBy = trc::location!()
);
continue;
}
},
Format::Tls(_) => match TlsReport::parse_json(&data) {
Ok(report) => {
// Log
report.log();
Format::Tls(report)
}
Err(err) => {
trc::event!(
IncomingReport(IncomingReportEvent::TlsRpcParseFailed),
SpanId = session_id,
From = from.to_string(),
Reason = format!("{err:?}"),
CausedBy = trc::location!()
);
continue;
}
},
Format::Arf(_) => match Feedback::parse_arf(&data) {
Some(report) => {
// Log
report.log();
Format::Arf(report.into_owned())
}
None => {
trc::event!(
IncomingReport(IncomingReportEvent::ArfParseFailed),
SpanId = session_id,
From = from.to_string(),
CausedBy = trc::location!()
);
continue;
}
},
};
// Store report
if let Some(expires_in) = &core.core.smtp.report.analysis.store {
let expires = now() + expires_in.as_secs();
let item_id = core.inner.data.queue_id_gen.generate();
let mut batch = BatchBuilder::new();
match report {
Format::Dmarc(report) => {
let mut report = DmarcExternalReport {
from,
to: to.into(),
subject,
member_tenant_id: None,
expires_at: UTCDateTime::from_timestamp(expires as i64),
received_at: UTCDateTime::now(),
report: report.into(),
};
report.member_tenant_id = tenant_ids(
&core,
report
.domains()
.filter_map(psl::domain_str)
.collect::<AHashSet<_>>(),
)
.await;
report.write_ops(&mut batch, item_id, true);
}
Format::Tls(report) => {
let mut report = TlsExternalReport {
from,
to: to.into(),
subject,
member_tenant_id: None,
expires_at: UTCDateTime::from_timestamp(expires as i64),
received_at: UTCDateTime::now(),
report: report.into(),
};
report.member_tenant_id = tenant_ids(
&core,
report
.domains()
.filter_map(psl::domain_str)
.collect::<AHashSet<_>>(),
)
.await;
report.write_ops(&mut batch, item_id, true);
}
Format::Arf(report) => {
let mut report = ArfExternalReport {
from,
to: to.into(),
subject,
member_tenant_id: None,
expires_at: UTCDateTime::from_timestamp(expires as i64),
received_at: UTCDateTime::now(),
report: report.into(),
};
report.member_tenant_id = tenant_ids(
&core,
report
.domains()
.filter_map(psl::domain_str)
.collect::<AHashSet<_>>(),
)
.await;
report.write_ops(&mut batch, item_id, true);
}
}
if let Err(err) = core.core.storage.data.write(batch.build_all()).await
&& !err.is_assertion_failure()
{
trc::error!(
err.span_id(session_id)
.caused_by(trc::location!())
.details("Failed to write report")
);
}
}
return;
}
});
}
}
async fn tenant_ids(server: &Server, domains: AHashSet<&str>) -> Option<Id> {
let mut tenant_ids = Vec::with_capacity(domains.len());
for domain in domains {
if let Some(tenant_id) = server
.domain(domain)
.await
.map_err(|err| {
trc::error!(
err.caused_by(trc::location!())
.details("Failed to lookup domain")
);
})
.unwrap_or_default()
.and_then(|domain| domain.id_tenant)
.map(Id::from)
&& !tenant_ids.contains(&tenant_id)
{
tenant_ids.push(tenant_id);
}
}
if tenant_ids.len() == 1 {
tenant_ids.into_iter().next()
} else {
None
}
}
fn read_capped(
reader: impl Read,
size_hint: u64,
max_size: usize,
) -> Result<Vec<u8>, std::io::Error> {
let max_size = max_size as u64;
if size_hint > max_size {
return Err(std::io::Error::other(format!(
"Report is larger than the {max_size} byte limit"
)));
}
let mut buf = Vec::with_capacity(size_hint.min(64 * 1024) as usize);
reader
.take(max_size.saturating_add(1))
.read_to_end(&mut buf)?;
if buf.len() as u64 > max_size {
return Err(std::io::Error::other(format!(
"Report is larger than the {max_size} byte limit"
)));
}
Ok(buf)
}
+109
View File
@@ -0,0 +1,109 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{core::Session, reporting::send::MtaReportSend};
use common::network::SessionStream;
use mail_auth::{
AuthenticatedMessage, AuthenticationResults, DkimOutput, common::verify::VerifySignature,
};
use registry::schema::structs::Rate;
use trc::OutgoingReportEvent;
impl<T: SessionStream> Session<T> {
pub async fn send_dkim_report(
&self,
rcpt: &str,
message: &AuthenticatedMessage<'_>,
rate: &Rate,
rejected: bool,
output: &DkimOutput<'_>,
) {
// Generate report
let signature = if let Some(signature) = output.signature() {
signature
} else {
return;
};
if self
.server
.is_local_report_domain(signature.domain(), self.data.session_id)
.await
{
return;
}
// Throttle recipient
if !self.throttle_rcpt(rcpt, rate, "dkim").await {
trc::event!(
OutgoingReport(OutgoingReportEvent::DkimRateLimited),
SpanId = self.data.session_id,
To = rcpt.to_string(),
Limit = vec![
trc::Value::from(rate.count),
trc::Value::from(rate.period.into_inner())
],
);
return;
}
let config = &self.server.core.smtp.report.dkim;
let from_addr = self
.server
.eval_if(&config.address, self, self.data.session_id)
.await
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_string());
let mut report = Vec::with_capacity(128);
self.new_auth_failure(output.result().into(), rejected)
.with_authentication_results(
AuthenticationResults::new(&self.hostname)
.with_dkim_result(output, message.from())
.to_string(),
)
.with_dkim_domain(signature.domain())
.with_dkim_selector(signature.selector())
.with_dkim_identity(signature.identity())
.with_headers(std::str::from_utf8(message.raw_headers()).unwrap_or_default())
.write_rfc5322(
(
self.server
.eval_if(&config.name, self, self.data.session_id)
.await
.unwrap_or_else(|| "Mail Delivery Subsystem".to_string())
.as_str(),
from_addr.as_str(),
),
rcpt,
&self
.server
.eval_if(&config.subject, self, self.data.session_id)
.await
.unwrap_or_else(|| "DKIM Report".to_string()),
&mut report,
)
.ok();
trc::event!(
OutgoingReport(OutgoingReportEvent::DkimReport),
SpanId = self.data.session_id,
From = from_addr.to_string(),
To = rcpt.to_string(),
);
// Send report
self.server
.send_report(
&from_addr,
[rcpt].into_iter(),
report,
&config.sign,
true,
self.data.session_id,
)
.await;
}
}
+695
View File
@@ -0,0 +1,695 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::AggregateTimestamp;
use crate::{
core::Session,
queue::RecipientDomain,
reporting::{index::InternalReportIndex, send::MtaReportSend},
};
use common::{
Server,
config::smtp::report::AggregateFrequency,
ipc::{DmarcEvent, ToHash},
network::SessionStream,
};
use compact_str::ToCompactString;
use mail_auth::{
ArcOutput, AuthenticatedMessage, AuthenticationResults, DkimOutput, DkimResult, DmarcOutput,
DmarcResult, SpfResult,
common::verify::VerifySignature,
dkim2::Dkim2Output,
dmarc::{self},
report::{AuthFailureType, IdentityAlignment, PolicyPublished, Record, SPFDomainScope},
};
use registry::{
schema::{
enums::FailureReportingOption,
prelude::{ObjectType, Property},
structs::{DmarcInternalReport, DmarcReport, DmarcReportRecord, Rate},
},
types::{EnumImpl, ObjectImpl, datetime::UTCDateTime, map::Map},
};
use std::{borrow::Cow, future::Future};
use store::{
SerializeInfallible, U64_LEN, ValueKey,
registry::ObjectIdVersioned,
write::{BatchBuilder, RegistryClass, ValueClass, assert::AssertValue, key::KeySerializer},
};
use trc::{AddContext, OutgoingReportEvent};
use utils::DomainPart;
impl<T: SessionStream> Session<T> {
#[allow(clippy::too_many_arguments)]
pub async fn send_dmarc_report(
&self,
message: &AuthenticatedMessage<'_>,
auth_results: &AuthenticationResults<'_>,
rejected: bool,
dmarc_output: DmarcOutput,
dkim_output: &[DkimOutput<'_>],
dkim2_output: Option<&Dkim2Output<'_>>,
arc_output: &Option<ArcOutput<'_>>,
) {
let dmarc_record = dmarc_output.dmarc_record_cloned().unwrap();
let config = &self.server.core.smtp.report.dmarc;
if self
.server
.is_local_report_domain(dmarc_output.domain(), self.data.session_id)
.await
{
return;
}
// Send failure report. RFC 9991 Section 2: report generators MUST NOT
// honor "ruf" for policy records published with "psd=y".
if !matches!(dmarc_record.psd, dmarc::Psd::Yes)
&& let (Some(failure_rate), Some(report_options)) = (
self.server
.eval_if::<Rate, _>(&config.send, self, self.data.session_id)
.await,
dmarc_output.failure_report(),
)
{
// Verify that any external reporting addresses are authorized
let rcpts = match self
.server
.core
.smtp
.resolvers
.dns
.verify_dmarc_report_address(
dmarc_output.domain(),
dmarc_record.ruf(),
Some(&self.server.inner.cache.dns_txt),
)
.await
{
Some(rcpts) => {
if !rcpts.is_empty() {
let mut new_rcpts = Vec::with_capacity(rcpts.len());
for rcpt in rcpts {
if self.throttle_rcpt(rcpt.uri(), &failure_rate, "dmarc").await {
new_rcpts.push(rcpt.uri());
}
}
new_rcpts
} else {
if !dmarc_record.ruf().is_empty() {
trc::event!(
OutgoingReport(OutgoingReportEvent::UnauthorizedReportingAddress),
SpanId = self.data.session_id,
Url = dmarc_record
.ruf()
.iter()
.map(|u| trc::Value::String(u.uri().to_compact_string()))
.collect::<Vec<_>>(),
);
}
vec![]
}
}
None => {
trc::event!(
OutgoingReport(OutgoingReportEvent::ReportingAddressValidationError),
SpanId = self.data.session_id,
Url = dmarc_record
.ruf()
.iter()
.map(|u| trc::Value::String(u.uri().to_compact_string()))
.collect::<Vec<_>>(),
);
vec![]
}
};
// Throttle recipient
if !rcpts.is_empty() {
let mut report = Vec::with_capacity(128);
let from_addr = self
.server
.eval_if(&config.address, self, self.data.session_id)
.await
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_compact_string());
let mut auth_failure = self
.new_auth_failure(AuthFailureType::Dmarc, rejected)
.with_authentication_results(auth_results.to_string())
.with_headers(std::str::from_utf8(message.raw_headers()).unwrap_or_default());
let dkim_aligned = matches!(dmarc_output.dkim_result(), DmarcResult::Pass);
let spf_aligned = matches!(dmarc_output.spf_result(), DmarcResult::Pass);
// Report the first failed signature
if let (
dmarc::Report::Dkim
| dmarc::Report::DkimSpf
| dmarc::Report::All
| dmarc::Report::Any,
Some(signature),
) = (
&report_options,
if !dkim_aligned {
dkim_output
.iter()
.find_map(|o| {
let s = o.signature()?;
if !matches!(o.result(), DkimResult::Pass) {
Some(s)
} else {
None
}
})
.or_else(|| dkim_output.iter().find_map(|o| o.signature()))
} else {
None
},
) {
auth_failure = auth_failure
.with_dkim_domain(signature.domain())
.with_dkim_selector(signature.selector())
.with_dkim_identity(signature.identity());
}
// Report SPF failure
if let (
dmarc::Report::Spf
| dmarc::Report::DkimSpf
| dmarc::Report::All
| dmarc::Report::Any,
Some(output),
) = (
&report_options,
if !spf_aligned {
self.data
.spf_ehlo
.as_ref()
.and_then(|s| {
if s.result() != SpfResult::Pass {
s.into()
} else {
None
}
})
.or_else(|| {
self.data.spf_mail_from.as_ref().and_then(|s| {
if s.result() != SpfResult::Pass {
s.into()
} else {
None
}
})
})
.or(self.data.spf_mail_from.as_ref())
} else {
None
},
) {
auth_failure =
auth_failure.with_spf_dns(format!("txt : {} : v=SPF1", output.domain()));
// TODO use DNS record
}
auth_failure
.with_identity_alignment(match (dkim_aligned, spf_aligned) {
(false, false) => IdentityAlignment::DkimSpf,
(false, true) => IdentityAlignment::Dkim,
(true, false) => IdentityAlignment::Spf,
(true, true) => IdentityAlignment::None,
})
.write_rfc5322(
(
self.server
.eval_if(&config.name, self, self.data.session_id)
.await
.unwrap_or_else(|| "Mail Delivery Subsystem".to_compact_string())
.as_str(),
from_addr.as_str(),
),
&rcpts.join(", "),
&self
.server
.eval_if(&config.subject, self, self.data.session_id)
.await
.unwrap_or_else(|| "DMARC Report".to_compact_string()),
&mut report,
)
.ok();
trc::event!(
OutgoingReport(OutgoingReportEvent::DmarcReport),
SpanId = self.data.session_id,
From = from_addr.to_string(),
To = rcpts
.iter()
.map(|a| trc::Value::String(a.to_compact_string()))
.collect::<Vec<_>>(),
);
// Send report
self.server
.send_report(
&from_addr,
rcpts.into_iter(),
report,
&config.sign,
true,
self.data.session_id,
)
.await;
} else {
trc::event!(
OutgoingReport(OutgoingReportEvent::DmarcRateLimited),
SpanId = self.data.session_id,
Limit = vec![
trc::Value::from(failure_rate.count),
trc::Value::from(failure_rate.period.into_inner())
],
);
}
}
// Send aggregate reports
let interval = self
.server
.eval_if(
&self.server.core.smtp.report.dmarc_aggregate.send,
self,
self.data.session_id,
)
.await
.unwrap_or(AggregateFrequency::Never);
if matches!(interval, AggregateFrequency::Never) || dmarc_record.rua().is_empty() {
return;
}
// Report the same identifier forms that were used for alignment
let message_from = message.from();
let header_from = message_from.domain_part();
let header_from = header_from
.to_ascii_domain()
.unwrap_or(Cow::Borrowed(header_from));
let envelope_from = self
.data
.mail_from
.as_ref()
.map(|mf| mf.domain.as_str())
.unwrap_or_else(|| self.data.helo_domain.as_str());
let envelope_from = envelope_from
.to_ascii_domain()
.unwrap_or(Cow::Borrowed(envelope_from));
// Create DMARC report record
let mut report_record = Record::new()
.with_dmarc_output(&dmarc_output)
.with_dkim_output(dkim_output)
.with_source_ip(self.data.remote_ip)
.with_header_from(header_from.as_ref())
.with_envelope_from(envelope_from.as_ref());
if let Some(dkim2_output) = dkim2_output {
report_record = report_record.with_dkim2_output(dkim2_output);
}
if let Some(spf_ehlo) = &self.data.spf_ehlo {
report_record = report_record.with_spf_output(spf_ehlo, SPFDomainScope::Helo);
}
if let Some(spf_mail_from) = &self.data.spf_mail_from {
report_record = report_record.with_spf_output(spf_mail_from, SPFDomainScope::MailFrom);
}
if let Some(arc_output) = arc_output {
report_record = report_record.with_arc_output(arc_output);
}
// Submit DMARC report event
self.server
.schedule_report(DmarcEvent {
domain: dmarc_output.into_domain(),
report_record,
dmarc_record,
interval,
span_id: self.data.session_id,
})
.await;
}
}
pub trait DmarcReporting: Sync + Send {
fn send_dmarc_aggregate_report(
&self,
report_id: u64,
) -> impl Future<Output = trc::Result<()>> + Send;
fn schedule_dmarc(&self, event: Box<DmarcEvent>) -> impl Future<Output = ()> + Send;
}
impl DmarcReporting for Server {
async fn send_dmarc_aggregate_report(&self, item_id: u64) -> trc::Result<()> {
let object_id = ObjectType::DmarcInternalReport.to_id();
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
let Some(report) = self
.store()
.get_value::<DmarcInternalReport>(ValueKey::from(key.clone()))
.await
.caused_by(trc::location!())?
else {
return Ok(());
};
// Delete report
let mut batch = BatchBuilder::new();
batch.clear(key).clear(RegistryClass::PrimaryKey {
object_id: object_id.into(),
index_id: Property::Domain.to_id(),
key: KeySerializer::new(report.domain.len() + U64_LEN)
.write(&report.domain)
.write(report.policy_identifier)
.finalize(),
});
self.store()
.write(batch.build_all())
.await
.caused_by(trc::location!())?;
let span_id = self.inner.data.span_id_gen.generate();
let event_from = report.report.date_range_begin.timestamp() as u64;
let event_to = report.report.date_range_end.timestamp() as u64;
trc::event!(
OutgoingReport(OutgoingReportEvent::DmarcAggregateReport),
SpanId = span_id,
ReportId = event_from,
Domain = report.domain.clone(),
RangeFrom = trc::Value::Timestamp(event_from),
RangeTo = trc::Value::Timestamp(event_to),
);
// Verify external reporting addresses
let rua = match self
.core
.smtp
.resolvers
.dns
.verify_dmarc_report_address(
&report.domain,
report.rua.as_slice(),
Some(&self.inner.cache.dns_txt),
)
.await
{
Some(rcpts) => {
if !rcpts.is_empty() {
rcpts
} else {
trc::event!(
OutgoingReport(OutgoingReportEvent::UnauthorizedReportingAddress),
SpanId = span_id,
Url = report
.rua
.into_iter()
.map(|u| trc::Value::String(u.into()))
.collect::<Vec<_>>(),
);
return Ok(());
}
}
None => {
trc::event!(
OutgoingReport(OutgoingReportEvent::ReportingAddressValidationError),
SpanId = span_id,
Url = report
.rua
.into_iter()
.map(|u| trc::Value::String(u.into()))
.collect::<Vec<_>>(),
);
return Ok(());
}
};
// Serialize report
let config = &self.core.smtp.report.dmarc_aggregate;
let from_addr = self
.eval_if(
&config.address,
&RecipientDomain::new(report.domain.as_str()),
span_id,
)
.await
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_compact_string());
let mut message = Vec::with_capacity(2048);
let _ = mail_auth::report::Report::from(report.report).write_rfc5322(
&self
.eval_if(
&self.core.smtp.report.submitter,
&RecipientDomain::new(report.domain.as_str()),
span_id,
)
.await
.unwrap_or_else(|| "localhost".to_compact_string()),
(
self.eval_if(
&config.name,
&RecipientDomain::new(report.domain.as_str()),
span_id,
)
.await
.unwrap_or_else(|| "Mail Delivery Subsystem".to_compact_string())
.as_str(),
from_addr.as_str(),
),
rua.iter().map(|a| a.as_str()),
&mut message,
);
// Send report
self.send_report(
&from_addr,
rua.iter(),
message,
&config.sign,
false,
span_id,
)
.await;
Ok(())
}
async fn schedule_dmarc(&self, event: Box<DmarcEvent>) {
let object_id = ObjectType::DmarcInternalReport.to_id();
let policy_hash = event.dmarc_record.to_hash();
let pk = ValueClass::Registry(RegistryClass::PrimaryKey {
object_id: object_id.into(),
index_id: Property::Domain.to_id(),
key: KeySerializer::new(event.domain.len() + U64_LEN)
.write(&event.domain)
.write(policy_hash)
.finalize(),
});
let mut rety_count = 0;
loop {
// Find the report by domain name
let mut batch = BatchBuilder::new();
let report = match self
.store()
.get_value::<ObjectIdVersioned>(ValueKey::from(pk.clone()))
.await
{
Ok(Some(object_id_v)) => {
match self
.store()
.get_value::<DmarcInternalReport>(ValueKey::from(ValueClass::Registry(
RegistryClass::Item {
object_id,
item_id: object_id_v.object_id.id().id(),
},
)))
.await
{
Ok(Some(report)) => Some((object_id_v, report)),
Ok(None) => {
trc::event!(
OutgoingReport(OutgoingReportEvent::NotFound),
Id = object_id_v.object_id.id().id(),
CausedBy = trc::location!(),
Details = "Failed to find DMARC report for domain"
);
return;
}
Err(err) => {
trc::error!(
err.caused_by(trc::location!())
.details("Failed to query registry for DMARC report")
);
return;
}
}
}
Ok(None) => None,
Err(err) => {
trc::error!(
err.caused_by(trc::location!())
.details("Failed to query registry for DMARC report")
);
return;
}
};
// Create report if missing
let config = &self.core.smtp.report.dmarc_aggregate;
let (item_id, mut report) = if let Some((mut object_id_v, report)) = report {
batch.assert_value(pk.clone(), AssertValue::U32(object_id_v.version));
object_id_v.version += 1;
batch.set(pk.clone(), object_id_v.serialize());
(object_id_v.object_id.id().id(), report)
} else {
let item_id = self.inner.data.queue_id_gen.generate();
let date_range_begin = UTCDateTime::now();
let date_range_end = UTCDateTime::from_timestamp(
date_range_begin.timestamp() + event.interval.as_secs() as i64,
);
let policy =
PolicyPublished::from_record(event.domain.clone(), &event.dmarc_record);
let report = DmarcInternalReport {
created_at: date_range_begin,
deliver_at: date_range_end,
domain: event.domain.clone(),
report: DmarcReport {
report_id: format!("{}_{policy_hash}", date_range_begin.timestamp()),
date_range_begin,
date_range_end,
email: self
.eval_if(
&config.address,
&RecipientDomain::new(event.domain.as_str()),
event.span_id,
)
.await
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_string()),
extra_contact_info: self
.eval_if::<String, _>(
&config.contact_info,
&RecipientDomain::new(event.domain.as_str()),
event.span_id,
)
.await,
org_name: self
.eval_if::<String, _>(
&config.org_name,
&RecipientDomain::new(event.domain.as_str()),
event.span_id,
)
.await
.unwrap_or_default(),
policy_adkim: policy.adkim.into(),
policy_aspf: policy.aspf.into(),
policy_disposition: policy.p.into(),
policy_domain: policy.domain,
policy_failure_reporting_options: match event.dmarc_record.fo {
dmarc::Report::All => vec![FailureReportingOption::All],
dmarc::Report::Any => vec![FailureReportingOption::Any],
dmarc::Report::Dkim => vec![FailureReportingOption::DkimFailure],
dmarc::Report::Spf => vec![FailureReportingOption::SpfFailure],
dmarc::Report::DkimSpf => vec![
FailureReportingOption::DkimFailure,
FailureReportingOption::SpfFailure,
],
}
.into(),
policy_subdomain_disposition: policy.sp.into(),
policy_np: policy.np.into(),
policy_discovery_method: policy.discovery_method.into(),
policy_testing_mode: policy.testing,
policy_version: None,
version: 1.0.into(),
..Default::default()
},
policy_identifier: policy_hash,
rua: Map::new(
event
.dmarc_record
.rua()
.iter()
.map(|u| u.uri.clone())
.collect(),
),
};
report.write_ops(&mut batch, item_id, true);
(item_id, report)
};
// Add record
let mut record = DmarcReportRecord::from(event.report_record.clone());
if let Some(idx) = report
.report
.records
.0
.inner
.iter()
.position(|d| d.value.eq_except_count(&record))
{
report.report.records.0.inner[idx].value.count += 1;
} else {
record.count = 1;
report.report.records.push(record);
}
// Write entry
let report_bytes = report.to_pickled_vec();
let max_report_size = self
.eval_if(
&config.max_size,
&RecipientDomain::new(&event.domain),
event.span_id,
)
.await
.unwrap_or(5 * 1024 * 1024);
if max_report_size != 0 && report_bytes.len() > max_report_size {
trc::event!(
OutgoingReport(OutgoingReportEvent::MaxSizeExceeded),
SpanId = event.span_id,
Domain = event.domain.clone(),
Details = report_bytes.len(),
Limit = max_report_size,
);
return;
}
batch.set(
ValueClass::Registry(RegistryClass::Item { object_id, item_id }),
report_bytes,
);
match self.core.storage.data.write(batch.build_all()).await {
Ok(_) => {
break;
}
Err(err) => {
if err.is_assertion_failure() && rety_count < 3 {
rety_count += 1;
continue;
}
trc::error!(
err.caused_by(trc::location!())
.details("Failed to write DMARC report")
);
break;
}
}
}
}
}
+208
View File
@@ -0,0 +1,208 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::core::Session;
use ahash::AHashMap;
use common::USER_AGENT;
use mail_auth::report::{
ActionDisposition, AuthFailureType, DeliveryResult, DmarcResult, Feedback, FeedbackType,
Report, tlsrpt::TlsReport,
};
use std::{collections::hash_map::Entry, time::SystemTime};
use store::write::now;
use tokio::io::{AsyncRead, AsyncWrite};
use trc::IncomingReportEvent;
impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
pub fn new_auth_failure(&self, ft: AuthFailureType, rejected: bool) -> Feedback<'_> {
Feedback::new(FeedbackType::AuthFailure)
.with_auth_failure(ft)
.with_arrival_date(
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs()) as i64,
)
.with_source_ip(self.data.remote_ip)
.with_reporting_mta(&self.hostname)
.with_user_agent(USER_AGENT)
.with_delivery_result(if rejected {
DeliveryResult::Reject
} else {
DeliveryResult::Unspecified
})
}
pub fn is_report(&self) -> bool {
let analysis = &self.server.core.smtp.report.analysis;
self.data
.rcpt_to
.iter()
.any(|addr| analysis.is_report_address(addr.report_address()))
}
}
pub(crate) trait LogReport {
fn log(&self);
}
impl LogReport for Report {
fn log(&self) {
let mut dmarc_pass = 0;
let mut dmarc_quarantine = 0;
let mut dmarc_reject = 0;
let mut dmarc_none = 0;
let mut dkim_pass = 0;
let mut dkim_fail = 0;
let mut dkim_none = 0;
let mut spf_pass = 0;
let mut spf_fail = 0;
let mut spf_none = 0;
for record in self.records() {
let count = std::cmp::min(record.count(), 1);
match record.action_disposition() {
ActionDisposition::Pass => {
dmarc_pass += count;
}
ActionDisposition::Quarantine => {
dmarc_quarantine += count;
}
ActionDisposition::Reject => {
dmarc_reject += count;
}
ActionDisposition::None | ActionDisposition::Unspecified => {
dmarc_none += count;
}
}
match record.dmarc_dkim_result() {
DmarcResult::Pass => {
dkim_pass += count;
}
DmarcResult::Fail => {
dkim_fail += count;
}
DmarcResult::Unspecified => {
dkim_none += count;
}
}
match record.dmarc_spf_result() {
DmarcResult::Pass => {
spf_pass += count;
}
DmarcResult::Fail => {
spf_fail += count;
}
DmarcResult::Unspecified => {
spf_none += count;
}
}
}
trc::event!(
IncomingReport(
if (dmarc_reject + dmarc_quarantine + dkim_fail + spf_fail) > 0 {
IncomingReportEvent::DmarcReportWithWarnings
} else {
IncomingReportEvent::DmarcReport
}
),
RangeFrom = trc::Value::Timestamp(self.date_range_begin()),
RangeTo = trc::Value::Timestamp(self.date_range_end()),
Domain = self.domain().to_string(),
From = self.email().to_string(),
Id = self.report_id().to_string(),
DmarcPass = dmarc_pass,
DmarcQuarantine = dmarc_quarantine,
DmarcReject = dmarc_reject,
DmarcNone = dmarc_none,
DkimPass = dkim_pass,
DkimFail = dkim_fail,
DkimNone = dkim_none,
SpfPass = spf_pass,
SpfFail = spf_fail,
SpfNone = spf_none,
);
}
}
impl LogReport for TlsReport {
fn log(&self) {
for policy in self.policies.iter().take(5) {
let mut details = AHashMap::with_capacity(policy.failure_details.len());
for failure in &policy.failure_details {
let num_failures = std::cmp::min(1, failure.failed_session_count);
match details.entry(failure.result_type) {
Entry::Occupied(mut e) => {
*e.get_mut() += num_failures;
}
Entry::Vacant(e) => {
e.insert(num_failures);
}
}
}
trc::event!(
IncomingReport(if policy.summary.total_failure > 0 {
IncomingReportEvent::TlsReportWithWarnings
} else {
IncomingReportEvent::TlsReport
}),
RangeFrom =
trc::Value::Timestamp(self.date_range.start_datetime.to_timestamp() as u64),
RangeTo = trc::Value::Timestamp(self.date_range.end_datetime.to_timestamp() as u64),
Domain = policy.policy.policy_domain.clone(),
From = self.contact_info.as_deref().unwrap_or_default().to_string(),
Id = self.report_id.clone(),
Policy = format!("{:?}", policy.policy.policy_type),
TotalSuccesses = policy.summary.total_success,
TotalFailures = policy.summary.total_failure,
Details = format!("{details:?}"),
);
}
}
}
impl LogReport for Feedback<'_> {
fn log(&self) {
trc::event!(
IncomingReport(match self.feedback_type() {
mail_auth::report::FeedbackType::Abuse => IncomingReportEvent::AbuseReport,
mail_auth::report::FeedbackType::AuthFailure =>
IncomingReportEvent::AuthFailureReport,
mail_auth::report::FeedbackType::Fraud => IncomingReportEvent::FraudReport,
mail_auth::report::FeedbackType::NotSpam => IncomingReportEvent::NotSpamReport,
mail_auth::report::FeedbackType::Other => IncomingReportEvent::OtherReport,
mail_auth::report::FeedbackType::Virus => IncomingReportEvent::VirusReport,
}),
RangeFrom = trc::Value::Timestamp(
self.arrival_date()
.map(|d| d as u64)
.unwrap_or_else(|| { now() })
),
Domain = self
.reported_domain()
.iter()
.map(|d| trc::Value::String(d.as_ref().into()))
.collect::<Vec<_>>(),
Hostname = self.reporting_mta().map(|d| trc::Value::String(d.into())),
Url = self
.reported_uri()
.iter()
.map(|d| trc::Value::String(d.as_ref().into()))
.collect::<Vec<_>>(),
RemoteIp = self.source_ip(),
Total = self.incidents(),
Result = format!("{:?}", self.delivery_result()),
Details = self
.authentication_results()
.iter()
.map(|d| trc::Value::String(d.as_ref().into()))
.collect::<Vec<_>>(),
);
}
}
+412
View File
@@ -0,0 +1,412 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use registry::{
schema::{
enums::DmarcActionDisposition,
prelude::{ObjectType, Property},
structs::{
ArfExternalReport, DmarcExternalReport, DmarcInternalReport, Task, TaskDmarcReport,
TaskStatus, TaskTlsReport, TlsExternalReport, TlsInternalReport,
},
},
types::{
EnumImpl, ObjectImpl,
datetime::UTCDateTime,
id::ObjectId,
index::{IndexBuilder, IndexValue},
},
};
use store::{
SerializeInfallible, U64_LEN,
registry::ObjectIdVersioned,
write::{
BatchBuilder, RegistryClass, TaskQueueClass, ValueClass, assert::AssertValue,
key::KeySerializer,
},
xxhash_rust::xxh3::Xxh3,
};
use types::id::Id;
pub trait InternalReportIndex: ObjectImpl {
fn deliver_at(&self) -> UTCDateTime;
fn set_deliver_at(&mut self, at: UTCDateTime);
fn task(&self, item_id: u64) -> Task;
fn primary_key(&self) -> ValueClass;
fn reschedule_ops(
&mut self,
batch: &mut BatchBuilder,
item_id: u64,
revision: u64,
at: UTCDateTime,
) {
let current_deliver_at = self.deliver_at();
if current_deliver_at != at {
let object = Self::OBJECT;
let object_id = object.to_id();
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
self.set_deliver_at(at);
batch
.assert_value(key.clone(), AssertValue::Hash(revision))
.clear(ValueClass::TaskQueue(TaskQueueClass::Due {
id: item_id,
due: current_deliver_at.timestamp() as u64,
}))
.set(
ValueClass::TaskQueue(TaskQueueClass::Due {
id: item_id,
due: at.timestamp() as u64,
}),
object_id.serialize(),
)
.set(key, self.to_pickled_vec());
}
}
fn write_ops(&self, batch: &mut BatchBuilder, item_id: u64, is_set: bool) {
let object = Self::OBJECT;
let object_id = object.to_id();
let pk = self.primary_key();
if is_set {
batch
.assert_value(pk.clone(), ())
.set(
pk,
ObjectIdVersioned {
object_id: ObjectId::new(object, item_id.into()),
version: 0,
}
.serialize(),
)
.schedule_task_with_id(item_id, self.task(item_id));
} else {
batch
.clear(ValueClass::Registry(RegistryClass::Item {
object_id,
item_id,
}))
.clear(pk)
.clear(ValueClass::TaskQueue(TaskQueueClass::Task { id: item_id }))
.clear(ValueClass::TaskQueue(TaskQueueClass::Due {
id: item_id,
due: self.deliver_at().timestamp() as u64,
}));
}
}
}
pub trait ExternalReportIndex: ObjectImpl {
fn text(&self) -> impl Iterator<Item = &str>;
fn tenant_id(&self) -> Option<Id>;
fn expires_at(&self) -> u64;
fn domains(&self) -> impl Iterator<Item = &str>;
fn success_fail_count(&self) -> (u64, u64);
fn unique_key(&self) -> Option<[u8; 16]>;
fn write_ops(&self, batch: &mut BatchBuilder, item_id: u64, is_set: bool) {
let object_id = Self::OBJECT.to_id();
let mut index_builder = IndexBuilder::default();
for text in self.text() {
index_builder.text(Property::Text, text);
}
if let Some(tenant_id) = self.tenant_id() {
index_builder.search(Property::MemberTenantId, tenant_id.id());
}
let (success_count, fail_count) = self.success_fail_count();
index_builder.search(Property::TotalSuccessfulSessions, success_count);
index_builder.search(Property::TotalFailedSessions, fail_count);
index_builder.search(Property::ExpiresAt, self.expires_at());
if let Some(unique_key) = self.unique_key() {
index_builder.unique(Property::ReportId, IndexValue::Bytes(unique_key.to_vec()));
}
batch.registry_index(object_id, item_id, index_builder.keys.iter(), is_set);
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
if is_set {
batch.set(key, self.to_pickled_vec());
} else {
batch.clear(key);
}
}
}
impl InternalReportIndex for DmarcInternalReport {
fn deliver_at(&self) -> UTCDateTime {
self.deliver_at
}
fn set_deliver_at(&mut self, at: UTCDateTime) {
self.deliver_at = at;
}
fn task(&self, item_id: u64) -> Task {
Task::DmarcReport(TaskDmarcReport {
report_id: item_id.into(),
status: TaskStatus::at(self.deliver_at.timestamp()),
})
}
fn primary_key(&self) -> ValueClass {
ValueClass::Registry(RegistryClass::PrimaryKey {
object_id: ObjectType::DmarcInternalReport.to_id().into(),
index_id: Property::Domain.to_id(),
key: KeySerializer::new(self.domain.len() + U64_LEN)
.write(self.domain.as_str())
.write(self.policy_identifier)
.finalize(),
})
}
}
impl InternalReportIndex for TlsInternalReport {
fn deliver_at(&self) -> UTCDateTime {
self.deliver_at
}
fn set_deliver_at(&mut self, at: UTCDateTime) {
self.deliver_at = at;
}
fn task(&self, item_id: u64) -> Task {
Task::TlsReport(TaskTlsReport {
report_id: item_id.into(),
status: TaskStatus::at(self.deliver_at.timestamp()),
})
}
fn primary_key(&self) -> ValueClass {
ValueClass::Registry(RegistryClass::PrimaryKey {
object_id: ObjectType::TlsInternalReport.to_id().into(),
index_id: Property::Domain.to_id(),
key: self.domain.as_bytes().to_vec(),
})
}
}
impl ExternalReportIndex for ArfExternalReport {
fn domains(&self) -> impl Iterator<Item = &str> {
let report = &self.report;
report
.reported_domains
.iter()
.filter_map(|s| non_empty(s))
.chain(
[report.dkim_domain.as_deref()]
.into_iter()
.flatten()
.filter_map(non_empty),
)
}
fn text(&self) -> impl Iterator<Item = &str> {
let report = &self.report;
report
.reported_domains
.iter()
.filter_map(|s| non_empty(s))
.chain(
[
report.dkim_domain.as_deref(),
report.reporting_mta.as_deref(),
report.original_mail_from.as_deref(),
report.original_rcpt_to.as_deref(),
]
.into_iter()
.flatten()
.filter_map(non_empty),
)
.chain(non_empty(&self.from))
}
fn tenant_id(&self) -> Option<Id> {
self.member_tenant_id
}
fn expires_at(&self) -> u64 {
self.expires_at.timestamp() as u64
}
fn success_fail_count(&self) -> (u64, u64) {
(self.report.incidents, 0)
}
fn unique_key(&self) -> Option<[u8; 16]> {
None
}
}
impl ExternalReportIndex for DmarcExternalReport {
fn domains(&self) -> impl Iterator<Item = &str> {
let report = &self.report;
non_empty(&report.policy_domain)
.into_iter()
.filter_map(non_empty)
}
fn text(&self) -> impl Iterator<Item = &str> {
let report = &self.report;
non_empty(&report.email)
.into_iter()
.filter_map(non_empty)
.chain(non_empty(&report.policy_domain))
.chain(report.records.iter().flat_map(|r| {
r.envelope_to
.as_deref()
.into_iter()
.filter_map(non_empty)
.chain(non_empty(&r.envelope_from))
.chain(non_empty(&r.header_from))
.chain(r.dkim_results.iter().filter_map(|d| non_empty(&d.domain)))
.chain(r.spf_results.iter().filter_map(|s| non_empty(&s.domain)))
}))
.chain(non_empty(&self.from))
}
fn tenant_id(&self) -> Option<Id> {
self.member_tenant_id
}
fn expires_at(&self) -> u64 {
self.expires_at.timestamp() as u64
}
fn success_fail_count(&self) -> (u64, u64) {
let mut success_count = 0;
let mut fail_count = 0;
for record in self.report.records.iter() {
if record.evaluated_disposition == DmarcActionDisposition::Pass {
success_count += std::cmp::min(record.count, 1);
} else {
fail_count += std::cmp::min(record.count, 1);
}
}
(success_count, fail_count)
}
fn unique_key(&self) -> Option<[u8; 16]> {
let report = &self.report;
Some(report_key(
[
report.org_name.as_str(),
report.policy_domain.as_str(),
report.report_id.as_str(),
],
report.date_range_begin,
report.date_range_end,
))
}
}
impl ExternalReportIndex for TlsExternalReport {
fn domains(&self) -> impl Iterator<Item = &str> {
let report = &self.report;
report
.policies
.iter()
.flat_map(|p| non_empty(&p.policy_domain).into_iter())
}
fn text(&self) -> impl Iterator<Item = &str> {
let report = &self.report;
report
.policies
.iter()
.flat_map(|p| {
non_empty(&p.policy_domain)
.into_iter()
.chain(p.mx_hosts.iter().filter_map(|s| non_empty(s)))
.chain(p.failure_details.iter().flat_map(|fd| {
non_empty_opt(&fd.receiving_mx_hostname)
.into_iter()
.chain(non_empty_opt(&fd.receiving_mx_helo))
}))
})
.chain(non_empty(&self.from))
}
fn tenant_id(&self) -> Option<Id> {
self.member_tenant_id
}
fn expires_at(&self) -> u64 {
self.expires_at.timestamp() as u64
}
fn success_fail_count(&self) -> (u64, u64) {
let mut success_count = 0;
let mut fail_count = 0;
for policy in self.report.policies.iter() {
success_count += std::cmp::min(policy.total_successful_sessions, 1);
fail_count += std::cmp::min(policy.total_failed_sessions, 1);
}
(success_count, fail_count)
}
fn unique_key(&self) -> Option<[u8; 16]> {
let report = &self.report;
Some(report_key(
[
report.organization_name.as_deref().unwrap_or_default(),
report.report_id.as_str(),
],
report.date_range_start,
report.date_range_end,
))
}
}
fn report_key<const N: usize>(fields: [&str; N], from: UTCDateTime, to: UTCDateTime) -> [u8; 16] {
let mut hasher = Xxh3::new();
for field in fields {
hasher.update(field.as_bytes());
hasher.update(&[0u8]);
}
hasher.update(&(from.timestamp() as u64).to_be_bytes());
hasher.update(&(to.timestamp() as u64).to_be_bytes());
hasher.digest128().to_be_bytes()
}
#[inline(always)]
fn non_empty(s: &str) -> Option<&str> {
if s.is_empty() { None } else { Some(s) }
}
#[inline(always)]
fn non_empty_opt(s: &Option<String>) -> Option<&str> {
s.as_deref().filter(|s| !s.is_empty())
}
+73
View File
@@ -0,0 +1,73 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use common::config::smtp::report::AggregateFrequency;
use mail_parser::DateTime;
use std::time::SystemTime;
pub mod analysis;
pub mod dkim;
pub mod dmarc;
pub mod inbound;
pub mod index;
pub mod scheduler;
pub mod send;
pub mod spf;
pub mod tls;
pub trait AggregateTimestamp {
fn to_timestamp(&self) -> u64;
fn to_timestamp_(&self, dt: DateTime) -> u64;
fn as_secs(&self) -> u64;
fn due(&self) -> u64;
}
impl AggregateTimestamp for AggregateFrequency {
fn to_timestamp(&self) -> u64 {
self.to_timestamp_(DateTime::from_timestamp(
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs()) as i64,
))
}
fn to_timestamp_(&self, mut dt: DateTime) -> u64 {
(match self {
AggregateFrequency::Hourly => {
dt.minute = 0;
dt.second = 0;
dt.to_timestamp()
}
AggregateFrequency::Daily => {
dt.hour = 0;
dt.minute = 0;
dt.second = 0;
dt.to_timestamp()
}
AggregateFrequency::Weekly => {
let dow = dt.day_of_week();
dt.hour = 0;
dt.minute = 0;
dt.second = 0;
dt.to_timestamp() - (86400 * dow as i64)
}
AggregateFrequency::Never => dt.to_timestamp(),
}) as u64
}
fn as_secs(&self) -> u64 {
match self {
AggregateFrequency::Hourly => 3600,
AggregateFrequency::Daily => 86400,
AggregateFrequency::Weekly => 7 * 86400,
AggregateFrequency::Never => 0,
}
}
fn due(&self) -> u64 {
self.to_timestamp() + self.as_secs()
}
}
+29
View File
@@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{dmarc::DmarcReporting, tls::TlsReporting};
use common::{BuildServer, Inner, ipc::ReportingEvent};
use std::sync::Arc;
use tokio::sync::mpsc;
pub trait SpawnReport {
fn spawn(self, core: Arc<Inner>);
}
impl SpawnReport for mpsc::Receiver<ReportingEvent> {
fn spawn(mut self, inner: Arc<Inner>) {
tokio::spawn(async move {
while let Some(event) = self.recv().await {
let server = inner.build_server();
match event {
ReportingEvent::Dmarc(event) => server.schedule_dmarc(event).await,
ReportingEvent::Tls(event) => server.schedule_tls(event).await,
ReportingEvent::Stop => break,
}
}
});
}
}
+150
View File
@@ -0,0 +1,150 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
inbound::dkim::DkimSign,
queue::{
MessageSource,
spool::{QueueParams, SmtpSpool},
},
};
use common::{Server, expr::if_block::IfBlock, ipc::ReportingEvent};
pub trait MtaReportSend: Sync + Send {
fn is_local_report_domain(
&self,
domain: &str,
session_id: u64,
) -> impl Future<Output = bool> + Send;
fn send_report(
&self,
from_addr: &str,
rcpts: impl Iterator<Item = impl AsRef<str> + Sync + Send> + Sync + Send,
report: Vec<u8>,
sign_config: &IfBlock,
deliver_now: bool,
parent_session_id: u64,
) -> impl Future<Output = ()> + Send;
fn send_autogenerated(
&self,
from_addr: impl AsRef<str> + Sync + Send,
rcpts: impl Iterator<Item = impl AsRef<str> + Sync + Send> + Sync + Send,
raw_message: Vec<u8>,
sign_config: Option<&IfBlock>,
parent_session_id: u64,
) -> impl Future<Output = ()> + Send;
fn schedule_report(
&self,
report: impl Into<ReportingEvent> + Sync + Send,
) -> impl Future<Output = ()> + Send;
}
impl MtaReportSend for Server {
async fn is_local_report_domain(&self, domain: &str, session_id: u64) -> bool {
match self.domain(domain).await {
Ok(domain) => domain.is_some(),
Err(err) => {
trc::error!(
err.caused_by(trc::location!())
.span_id(session_id)
.details("Failed to lookup local domain")
);
false
}
}
}
async fn send_report(
&self,
from_addr: &str,
rcpts: impl Iterator<Item = impl AsRef<str> + Sync + Send> + Sync + Send,
report: Vec<u8>,
sign_config: &IfBlock,
deliver_now: bool,
parent_session_id: u64,
) {
// Build message
let mut message = self.new_message(from_addr, MessageSource::Report, parent_session_id);
for rcpt_ in rcpts {
message.add_expanded_recipient(rcpt_.as_ref(), self).await;
}
// Schedule delivery at a random time between now and the next 3 hours
if !deliver_now {
#[cfg(not(feature = "test_mode"))]
{
use common::config::smtp::queue::QueueExpiry;
use rand::RngExt;
let delivery_time = rand::rng().random_range(0u64..10800u64);
for rcpt in &mut message.message.recipients {
rcpt.retry.due += delivery_time;
rcpt.notify.due += delivery_time;
if let QueueExpiry::Ttl(expires) = &mut rcpt.expires {
*expires += delivery_time;
}
}
}
}
// Queue message
let dkim_signers = self
.eval_signers(sign_config, &message.message, parent_session_id)
.await;
message
.queue(
QueueParams::new(&report, parent_session_id, self).with_dkim_signers(dkim_signers),
)
.await;
}
async fn send_autogenerated(
&self,
from_addr: impl AsRef<str> + Sync + Send,
rcpts: impl Iterator<Item = impl AsRef<str> + Sync + Send> + Sync + Send,
raw_message: Vec<u8>,
sign_config: Option<&IfBlock>,
parent_session_id: u64,
) {
// Build message
let mut message = self.new_message(
from_addr.as_ref(),
MessageSource::Autogenerated,
parent_session_id,
);
for rcpt in rcpts {
message.add_expanded_recipient(rcpt, self).await;
}
// Queue message
let dkim_signers = if let Some(sign_config) = sign_config {
self.eval_signers(sign_config, &message.message, parent_session_id)
.await
} else {
None
};
message
.queue(
QueueParams::new(&raw_message, parent_session_id, self)
.with_dkim_signers(dkim_signers),
)
.await;
}
async fn schedule_report(&self, report: impl Into<ReportingEvent> + Sync + Send) {
if self.inner.ipc.report_tx.send(report.into()).await.is_err() {
trc::event!(
Server(trc::ServerEvent::ThreadError),
CausedBy = trc::location!(),
Details = "Failed to send event to ReportScheduler"
);
}
}
}
+101
View File
@@ -0,0 +1,101 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{core::Session, reporting::send::MtaReportSend};
use common::network::SessionStream;
use mail_auth::{AuthenticationResults, SpfOutput, report::AuthFailureType};
use registry::schema::structs::Rate;
use trc::OutgoingReportEvent;
impl<T: SessionStream> Session<T> {
pub async fn send_spf_report(
&self,
rcpt: &str,
rate: &Rate,
rejected: bool,
output: &SpfOutput,
) {
// Throttle recipient
if !self.throttle_rcpt(rcpt, rate, "spf").await {
trc::event!(
OutgoingReport(OutgoingReportEvent::SpfRateLimited),
SpanId = self.data.session_id,
To = rcpt.to_string(),
Limit = vec![
trc::Value::from(rate.count),
trc::Value::from(rate.period.into_inner())
],
);
return;
}
// Generate report
let config = &self.server.core.smtp.report.spf;
let from_addr = self
.server
.eval_if(&config.address, self, self.data.session_id)
.await
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_string());
let mut report = Vec::with_capacity(128);
self.new_auth_failure(AuthFailureType::Spf, rejected)
.with_authentication_results(
if let Some(mail_from) = &self.data.mail_from {
AuthenticationResults::new(&self.hostname).with_spf_mailfrom_result(
output,
self.data.remote_ip,
&mail_from.address,
&self.data.helo_domain,
)
} else {
AuthenticationResults::new(&self.hostname).with_spf_ehlo_result(
output,
self.data.remote_ip,
&self.data.helo_domain,
)
}
.to_string(),
)
.with_spf_dns(format!("txt : {} : v=SPF1", output.domain())) // TODO use DNS record
.write_rfc5322(
(
self.server
.eval_if(&config.name, self, self.data.session_id)
.await
.unwrap_or_else(|| "Mailer Daemon".to_string())
.as_str(),
from_addr.as_str(),
),
rcpt,
&self
.server
.eval_if(&config.subject, self, self.data.session_id)
.await
.unwrap_or_else(|| "SPF Report".to_string()),
&mut report,
)
.ok();
trc::event!(
OutgoingReport(OutgoingReportEvent::SpfReport),
SpanId = self.data.session_id,
To = rcpt.to_string(),
From = from_addr.to_string(),
);
// Send report
self.server
.send_report(
&from_addr,
[rcpt].into_iter(),
report,
&config.sign,
true,
self.data.session_id,
)
.await;
}
}
+493
View File
@@ -0,0 +1,493 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::AggregateTimestamp;
use crate::{
queue::RecipientDomain,
reporting::{index::InternalReportIndex, send::MtaReportSend},
};
use common::{
Server, USER_AGENT,
config::smtp::{
report::AggregateFrequency,
resolver::{Mode, MxPattern, TlsaMatching},
},
ipc::{TlsEvent, ToHash},
};
use mail_auth::{
flate2::{Compression, write::GzEncoder},
mta_sts::{ReportUri, TlsRpt},
report::tlsrpt::{FailureDetails, PolicyDetails},
};
use registry::{
schema::{
enums::TlsPolicyType,
prelude::{ObjectType, Property},
structs::{TlsFailureDetails, TlsInternalReport, TlsReport, TlsReportPolicy},
},
types::{EnumImpl, ObjectImpl, datetime::UTCDateTime},
};
use reqwest::header::CONTENT_TYPE;
use std::fmt::Write;
use std::{future::Future, sync::Arc, time::Duration};
use store::{
SerializeInfallible, ValueKey,
registry::ObjectIdVersioned,
write::{BatchBuilder, RegistryClass, ValueClass, assert::AssertValue},
};
use trc::{AddContext, OutgoingReportEvent};
#[derive(Debug, Clone)]
pub struct TlsRptOptions {
pub record: Arc<TlsRpt>,
pub interval: AggregateFrequency,
}
#[derive(Debug, rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, serde::Serialize)]
pub struct TlsFormat {
pub rua: Vec<ReportUri>,
pub policy: PolicyDetails,
pub records: Vec<Option<FailureDetails>>,
}
#[cfg(feature = "test_mode")]
pub static TLS_HTTP_REPORT: parking_lot::Mutex<Vec<u8>> = parking_lot::Mutex::new(Vec::new());
pub trait TlsReporting: Sync + Send {
fn send_tls_aggregate_report(
&self,
report_id: u64,
) -> impl Future<Output = trc::Result<()>> + Send;
fn schedule_tls(&self, event: Box<TlsEvent>) -> impl Future<Output = ()> + Send;
}
impl TlsReporting for Server {
async fn send_tls_aggregate_report(&self, item_id: u64) -> trc::Result<()> {
let object_id = ObjectType::TlsInternalReport.to_id();
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
let Some(report) = self
.store()
.get_value::<TlsInternalReport>(ValueKey::from(key.clone()))
.await
.caused_by(trc::location!())?
else {
return Ok(());
};
// Delete report
let mut batch = BatchBuilder::new();
batch.clear(key).clear(RegistryClass::PrimaryKey {
object_id: object_id.into(),
index_id: Property::Domain.to_id(),
key: report.domain.as_bytes().to_vec(),
});
self.core
.storage
.data
.write(batch.build_all())
.await
.caused_by(trc::location!())?;
let domain_name = report.domain.as_str();
let event_from = report.report.date_range_start.timestamp() as u64;
let event_to = report.report.date_range_end.timestamp() as u64;
let span_id = self.inner.data.span_id_gen.generate();
trc::event!(
OutgoingReport(OutgoingReportEvent::TlsAggregate),
SpanId = span_id,
ReportId = event_from,
Domain = domain_name.to_string(),
RangeFrom = trc::Value::Timestamp(event_from),
RangeTo = trc::Value::Timestamp(event_to),
);
// Generate report
let exported_report = mail_auth::report::tlsrpt::TlsReport::from(report.report);
let json = exported_report.to_json();
let mut e = GzEncoder::new(Vec::with_capacity(json.len()), Compression::default());
let json = match std::io::Write::write_all(&mut e, json.as_bytes()).and_then(|_| e.finish())
{
Ok(report) => report,
Err(err) => {
trc::event!(
OutgoingReport(OutgoingReportEvent::SubmissionError),
SpanId = span_id,
Reason = err.to_string(),
Details = "Failed to compress report"
);
return Ok(());
}
};
// Try delivering report over HTTP
for uri in report.http_rua.as_slice() {
{
#[cfg(feature = "test_mode")]
if uri == "https://127.0.0.1/tls" {
TLS_HTTP_REPORT.lock().extend_from_slice(&json);
return Ok(());
}
match self
.core
.smtp
.tls_report_client
.post(uri)
.timeout(Duration::from_secs(2 * 60))
.header(reqwest::header::USER_AGENT, USER_AGENT)
.header(CONTENT_TYPE, "application/tlsrpt+gzip")
.body(json.to_vec())
.send()
.await
{
Ok(response) => {
if response.status().is_success() {
trc::event!(
OutgoingReport(OutgoingReportEvent::HttpSubmission),
SpanId = span_id,
Url = uri.to_string(),
Code = response.status().as_u16(),
);
return Ok(());
} else {
trc::event!(
OutgoingReport(OutgoingReportEvent::SubmissionError),
SpanId = span_id,
Url = uri.to_string(),
Code = response.status().as_u16(),
Details = "Invalid HTTP response"
);
}
}
Err(err) => {
trc::event!(
OutgoingReport(OutgoingReportEvent::SubmissionError),
SpanId = span_id,
Url = uri.to_string(),
Reason = err.to_string(),
Details = "HTTP submission error"
);
}
}
}
}
// Deliver report over SMTP
if !report.mail_rua.is_empty() {
let config = &self.core.smtp.report.tls;
let from_addr = self
.eval_if(&config.address, &RecipientDomain::new(domain_name), span_id)
.await
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_string());
let mut message = Vec::with_capacity(2048);
let _ = exported_report.write_rfc5322_from_bytes(
domain_name,
&self
.eval_if(
&self.core.smtp.report.submitter,
&RecipientDomain::new(domain_name),
span_id,
)
.await
.unwrap_or_else(|| "localhost".to_string()),
(
self.eval_if(&config.name, &RecipientDomain::new(domain_name), span_id)
.await
.unwrap_or_else(|| "Mail Delivery Subsystem".to_string())
.as_str(),
from_addr.as_str(),
),
report.mail_rua.iter().map(|v| v.as_str()),
&json,
&mut message,
);
// Send report
self.send_report(
&from_addr,
report.mail_rua.iter().map(|v| v.as_str()),
message,
&config.sign,
false,
span_id,
)
.await;
} else {
trc::event!(
OutgoingReport(OutgoingReportEvent::NoRecipientsFound),
SpanId = span_id,
);
}
Ok(())
}
async fn schedule_tls(&self, event: Box<TlsEvent>) {
let object_id = ObjectType::TlsInternalReport.to_id();
let pk = ValueClass::Registry(RegistryClass::PrimaryKey {
object_id: object_id.into(),
index_id: Property::Domain.to_id(),
key: event.domain.as_bytes().to_vec(),
});
let mut rety_count = 0;
let policy_hash = event.policy.to_hash();
loop {
// Find the report by domain name
let mut batch = BatchBuilder::new();
let report = match self
.store()
.get_value::<ObjectIdVersioned>(ValueKey::from(pk.clone()))
.await
{
Ok(Some(object_id_v)) => {
match self
.store()
.get_value::<TlsInternalReport>(ValueKey::from(ValueClass::Registry(
RegistryClass::Item {
object_id,
item_id: object_id_v.object_id.id().id(),
},
)))
.await
{
Ok(Some(report)) => Some((object_id_v, report)),
Ok(None) => {
trc::event!(
OutgoingReport(OutgoingReportEvent::NotFound),
Id = object_id_v.object_id.id().id(),
CausedBy = trc::location!(),
Details = "Failed to find TLS report for domain"
);
return;
}
Err(err) => {
trc::error!(
err.caused_by(trc::location!())
.details("Failed to query registry for TLS report")
);
return;
}
}
}
Ok(None) => None,
Err(err) => {
trc::error!(
err.caused_by(trc::location!())
.details("Failed to query registry for TLS report")
);
return;
}
};
// Create report if missing
let config = &self.core.smtp.report.tls;
let (item_id, mut report) = if let Some((mut object_id_v, report)) = report {
batch.assert_value(pk.clone(), AssertValue::U32(object_id_v.version));
object_id_v.version += 1;
batch.set(pk.clone(), object_id_v.serialize());
(object_id_v.object_id.id().id(), report)
} else {
let item_id = self.inner.data.queue_id_gen.generate();
let date_range_start = UTCDateTime::now();
let date_range_end = UTCDateTime::from_timestamp(
date_range_start.timestamp() + event.interval.as_secs() as i64,
);
let report = TlsInternalReport {
created_at: date_range_start,
deliver_at: date_range_end,
domain: event.domain.clone(),
report: TlsReport {
report_id: format!("{}_{policy_hash}", date_range_start.timestamp()),
organization_name: self
.eval_if::<String, _>(
&config.org_name,
&RecipientDomain::new(&event.domain),
event.span_id,
)
.await
.clone(),
contact_info: self
.eval_if::<String, _>(
&config.contact_info,
&RecipientDomain::new(&event.domain),
event.span_id,
)
.await
.clone(),
date_range_end,
date_range_start,
policies: Default::default(),
},
..Default::default()
};
report.write_ops(&mut batch, item_id, true);
(item_id, report)
};
let policy = if let Some(policy) = report
.policy_identifiers
.as_slice()
.iter()
.position(|id| *id == policy_hash)
.and_then(|idx| report.report.policies.0.inner.get_mut(idx))
{
&mut policy.value
} else {
// Create policy
let mut policy = TlsReportPolicy {
policy_type: TlsPolicyType::NoPolicyFound,
policy_domain: report.domain.clone(),
..Default::default()
};
match &event.policy {
common::ipc::PolicyType::Tlsa(tlsa) => {
policy.policy_type = TlsPolicyType::Tlsa;
if let Some(tlsa) = tlsa {
for entry in &tlsa.entries {
policy.policy_strings.push(format!(
"{} {} {} {}",
if entry.is_end_entity { 3 } else { 2 },
i32::from(entry.is_spki),
match entry.matching {
TlsaMatching::Full => 0,
TlsaMatching::Sha256 => 1,
TlsaMatching::Sha512 => 2,
},
entry.data.iter().fold(
String::with_capacity(64),
|mut s, b| {
write!(s, "{b:02X}").ok();
s
}
)
));
}
}
}
common::ipc::PolicyType::Sts(sts) => {
policy.policy_type = TlsPolicyType::Sts;
if let Some(sts) = sts {
policy.policy_strings.push("version: STSv1".to_string());
policy.policy_strings.push(format!(
"mode: {}",
match sts.mode {
Mode::Enforce => "enforce",
Mode::Testing => "testing",
Mode::None => "none",
}
));
policy
.policy_strings
.push(format!("max_age: {}", sts.max_age));
for mx in &sts.mx {
let mx = match mx {
MxPattern::Equals(mx) => mx.to_string(),
MxPattern::StartsWith(mx) => format!("*.{mx}"),
};
policy.policy_strings.push(format!("mx: {mx}"));
policy.mx_hosts.push(mx);
}
}
}
_ => (),
}
for rua in &event.tls_record.rua {
match rua {
ReportUri::Mail(mail) => {
report.mail_rua.push(mail.clone());
}
ReportUri::Http(uri) => {
report.http_rua.push(uri.clone());
}
}
}
report.policy_identifiers.push(policy_hash);
report.report.policies.push(policy);
&mut report.report.policies.0.inner.last_mut().unwrap().value
};
// Add failure details
if let Some(mut failure) = event.failure.clone().map(TlsFailureDetails::from) {
if let Some(idx) = policy
.failure_details
.0
.inner
.iter()
.position(|d| d.value.eq_except_count(&failure))
{
policy.failure_details.0.inner[idx]
.value
.failed_session_count += 1;
} else {
failure.failed_session_count = 1;
policy.failure_details.push(failure);
}
policy.total_failed_sessions += 1;
} else {
policy.total_successful_sessions += 1;
}
// Write entry
let report_bytes = report.to_pickled_vec();
let max_report_size = self
.eval_if(
&config.max_size,
&RecipientDomain::new(&event.domain),
event.span_id,
)
.await
.unwrap_or(5 * 1024 * 1024);
if max_report_size != 0 && report_bytes.len() > max_report_size {
trc::event!(
OutgoingReport(OutgoingReportEvent::MaxSizeExceeded),
SpanId = event.span_id,
Domain = event.domain.clone(),
Details = report_bytes.len(),
Limit = max_report_size,
);
return;
}
batch.set(
ValueClass::Registry(RegistryClass::Item { object_id, item_id }),
report_bytes,
);
match self.core.storage.data.write(batch.build_all()).await {
Ok(_) => {
break;
}
Err(err) => {
if err.is_assertion_failure() && rety_count < 3 {
rety_count += 1;
continue;
}
trc::error!(
err.caused_by(trc::location!())
.details("Failed to write TLS report")
);
break;
}
}
}
}
}