Compare commits

..
Author SHA1 Message Date
jcoffey-dev 5c506b9d2b List what a hold export can't read instead of skipping it (LH-12)
ci / fork-checks (pull_request) Successful in 49s
ci / build (pull_request) Successful in 11m32s
An item the hold covers whose stored record or content can't be read
goes in exceptions.csv with the path it would have had and the reason,
rather than being left out silently. The file is always in the ZIP, so a
header-only one shows nothing was missed, and manifest.sha256 carries
its hash beside the manifest's.
2026-09-27 22:15:05 -07:00
7 changed files with 168 additions and 359 deletions
+126 -20
View File
@@ -9,6 +9,9 @@
//! calendars, contacts and files, and the deleted items the hold keeps, each //! calendars, contacts and files, and the deleted items the hold keeps, each
//! with its SHA-256 in `manifest.csv`, and the manifest's own hash beside //! with its SHA-256 in `manifest.csv`, and the manifest's own hash beside
//! it. The hold's date range applies as it does to what's kept (LH-3). //! it. The hold's date range applies as it does to what's kept (LH-3).
//! Anything the hold covers that can't be read goes in `exceptions.csv`
//! with the reason, never silently left out; the file is always there, so an
//! empty one says nothing was missed.
use common::{Server, hold::kept_member}; use common::{Server, hold::kept_member};
use email::{ use email::{
@@ -52,6 +55,21 @@ struct Entry {
sha256: String, sha256: String,
} }
/// One line of `exceptions.csv`: an item the hold covers that couldn't be
/// read, where it would have gone and why.
struct Missing {
path: String,
account: String,
kind: &'static str,
folder: String,
date: Option<i64>,
archived: bool,
reason: &'static str,
}
const NO_BLOB: &str = "content not found in the blob store";
const NO_RECORD: &str = "stored record not found";
fn hex(bytes: &[u8]) -> String { fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect() bytes.iter().map(|b| format!("{b:02x}")).collect()
} }
@@ -82,6 +100,7 @@ fn date_text(at: Option<i64>) -> String {
struct Builder { struct Builder {
zip: ZipWriter<Cursor<Vec<u8>>>, zip: ZipWriter<Cursor<Vec<u8>>>,
entries: Vec<Entry>, entries: Vec<Entry>,
missing: Vec<Missing>,
written: u64, written: u64,
} }
@@ -90,6 +109,7 @@ impl Builder {
Builder { Builder {
zip: ZipWriter::new(Cursor::new(Vec::new())), zip: ZipWriter::new(Cursor::new(Vec::new())),
entries: Vec::new(), entries: Vec::new(),
missing: Vec::new(),
written: 0, written: 0,
} }
} }
@@ -144,8 +164,31 @@ impl Builder {
Ok(()) Ok(())
} }
/// Closes the ZIP with its manifest and the manifest's hash. Returns the /// Records an item the hold covers that couldn't be read.
/// bytes and how many items went in. #[allow(clippy::too_many_arguments)]
fn missing(
&mut self,
path: String,
account: &str,
kind: &'static str,
folder: &str,
date: Option<i64>,
archived: bool,
reason: &'static str,
) {
self.missing.push(Missing {
path,
account: account.to_string(),
kind,
folder: folder.to_string(),
date,
archived,
reason,
});
}
/// Closes the ZIP with its manifest, the exceptions and both hashes.
/// Returns the bytes and how many items went in.
fn finish(mut self) -> trc::Result<(Vec<u8>, usize)> { fn finish(mut self) -> trc::Result<(Vec<u8>, usize)> {
let mut manifest = String::from("path,account,kind,folder,date,archived,size,sha256\n"); let mut manifest = String::from("path,account,kind,folder,date,archived,size,sha256\n");
for e in &self.entries { for e in &self.entries {
@@ -161,7 +204,21 @@ impl Builder {
e.sha256 e.sha256
)); ));
} }
let mut exceptions = String::from("path,account,kind,folder,date,archived,reason\n");
for m in &self.missing {
exceptions.push_str(&format!(
"{},{},{},{},{},{},{}\n",
csv(&m.path),
csv(&m.account),
m.kind,
csv(&m.folder),
date_text(m.date),
m.archived,
csv(m.reason)
));
}
let manifest_hash = hex(&Sha256::digest(manifest.as_bytes())); let manifest_hash = hex(&Sha256::digest(manifest.as_bytes()));
let exceptions_hash = hex(&Sha256::digest(exceptions.as_bytes()));
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
let fail = |err: zip::result::ZipError| { let fail = |err: zip::result::ZipError| {
trc::StoreEvent::UnexpectedError trc::StoreEvent::UnexpectedError
@@ -171,9 +228,11 @@ impl Builder {
}; };
self.zip.start_file("manifest.csv", options).map_err(fail)?; self.zip.start_file("manifest.csv", options).map_err(fail)?;
self.zip.write_all(manifest.as_bytes()).map_err(|e| fail(e.into()))?; self.zip.write_all(manifest.as_bytes()).map_err(|e| fail(e.into()))?;
self.zip.start_file("exceptions.csv", options).map_err(fail)?;
self.zip.write_all(exceptions.as_bytes()).map_err(|e| fail(e.into()))?;
self.zip.start_file("manifest.sha256", options).map_err(fail)?; self.zip.start_file("manifest.sha256", options).map_err(fail)?;
self.zip self.zip
.write_all(format!("{manifest_hash} manifest.csv\n").as_bytes()) .write_all(format!("{manifest_hash} manifest.csv\n{exceptions_hash} exceptions.csv\n").as_bytes())
.map_err(|e| fail(e.into()))?; .map_err(|e| fail(e.into()))?;
let items = self.entries.len(); let items = self.entries.len();
let bytes = self.zip.finish().map_err(fail)?.into_inner(); let bytes = self.zip.finish().map_err(fail)?.into_inner();
@@ -234,6 +293,19 @@ pub async fn build(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<(
.await .await
.caused_by(trc::location!())?; .caused_by(trc::location!())?;
for message in cache.emails.items.iter() { for message in cache.emails.items.iter() {
let mail_path = |folder: &str| {
format!(
"{base}mail/{}/{}.eml",
folder.split('/').map(segment).collect::<Vec<_>>().join("/"),
Id::from(message.document_id)
)
};
let folder = message
.mailboxes
.first()
.and_then(|m| cache.mailboxes.items.iter().find(|b| b.document_id == m.mailbox_id))
.map(|b| b.path.clone())
.unwrap_or_default();
let Some(metadata_) = data let Some(metadata_) = data
.get_value::<Archive<AlignedBytes>>(ValueKey::property( .get_value::<Archive<AlignedBytes>>(ValueKey::property(
account_id, account_id,
@@ -243,6 +315,8 @@ pub async fn build(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<(
)) ))
.await? .await?
else { else {
// No date to check against the hold's range, so it's listed
out.missing(mail_path(&folder), &address, "email", &folder, None, false, NO_RECORD);
continue; continue;
}; };
let metadata = metadata_ let metadata = metadata_
@@ -252,20 +326,20 @@ pub async fn build(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<(
if !keeping.covers(Some(received)) { if !keeping.covers(Some(received)) {
continue; continue;
} }
let folder = message
.mailboxes
.first()
.and_then(|m| cache.mailboxes.items.iter().find(|b| b.document_id == m.mailbox_id))
.map(|b| b.path.clone())
.unwrap_or_default();
let hash = types::blob_hash::BlobHash::from(&metadata.blob_hash); let hash = types::blob_hash::BlobHash::from(&metadata.blob_hash);
if let Some(bytes) = blob(server, hash.as_slice()).await? { match blob(server, hash.as_slice()).await? {
let path = format!( Some(bytes) => {
"{base}mail/{}/{}.eml", out.add(mail_path(&folder), &bytes, &address, "email", &folder, Some(received as i64), false)?
folder.split('/').map(segment).collect::<Vec<_>>().join("/"), }
Id::from(message.document_id) None => out.missing(
); mail_path(&folder),
out.add(path, &bytes, &address, "email", &folder, Some(received as i64), false)?; &address,
"email",
&folder,
Some(received as i64),
false,
NO_BLOB,
),
} }
} }
@@ -315,6 +389,7 @@ pub async fn build(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<(
)) ))
.await? .await?
else { else {
out.missing(zip_path(Some(".ics")), &address, kind, folder, Some(*start), false, NO_RECORD);
continue; continue;
}; };
let event = event_.unarchive::<CalendarEvent>().caused_by(trc::location!())?; let event = event_.unarchive::<CalendarEvent>().caused_by(trc::location!())?;
@@ -330,6 +405,7 @@ pub async fn build(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<(
)) ))
.await? .await?
else { else {
out.missing(zip_path(Some(".vcf")), &address, kind, folder, None, false, NO_RECORD);
continue; continue;
}; };
let card = card_.unarchive::<ContactCard>().caused_by(trc::location!())?; let card = card_.unarchive::<ContactCard>().caused_by(trc::location!())?;
@@ -346,15 +422,18 @@ pub async fn build(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<(
)) ))
.await? .await?
else { else {
out.missing(zip_path(None), &address, kind, folder, None, false, NO_RECORD);
continue; continue;
}; };
let file = file_.unarchive::<FileNode>().caused_by(trc::location!())?; let file = file_.unarchive::<FileNode>().caused_by(trc::location!())?;
let Some(props) = file.file.as_ref() else { let Some(props) = file.file.as_ref() else {
out.missing(zip_path(None), &address, kind, folder, None, false, NO_RECORD);
continue; continue;
}; };
let hash = types::blob_hash::BlobHash::from(&props.blob_hash); let hash = types::blob_hash::BlobHash::from(&props.blob_hash);
if let Some(bytes) = blob(server, hash.as_slice()).await? { match blob(server, hash.as_slice()).await? {
out.add(zip_path(None), &bytes, &address, kind, folder, None, false)?; Some(bytes) => out.add(zip_path(None), &bytes, &address, kind, folder, None, false)?,
None => out.missing(zip_path(None), &address, kind, folder, None, false, NO_BLOB),
} }
} }
_ => {} _ => {}
@@ -388,8 +467,10 @@ pub async fn build(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<(
ArchivedItem::SieveScript(s) => format!("{}{ext}", segment(&s.name)), ArchivedItem::SieveScript(s) => format!("{}{ext}", segment(&s.name)),
_ => format!("{id}{ext}"), _ => format!("{id}{ext}"),
}; };
if let Some(bytes) = blob(server, item.blob_id().hash.as_slice()).await? { let path = format!("{base}archived/{kind}/{name}");
out.add(format!("{base}archived/{kind}/{name}"), &bytes, &address, kind, "", date, true)?; match blob(server, item.blob_id().hash.as_slice()).await? {
Some(bytes) => out.add(path, &bytes, &address, kind, "", date, true)?,
None => out.missing(path, &address, kind, "", date, true, NO_BLOB),
} }
} }
} }
@@ -425,5 +506,30 @@ mod tests {
let mut hash = String::new(); let mut hash = String::new();
std::io::Read::read_to_string(&mut zip.by_name("manifest.sha256").unwrap(), &mut hash).unwrap(); std::io::Read::read_to_string(&mut zip.by_name("manifest.sha256").unwrap(), &mut hash).unwrap();
assert!(hash.starts_with(&hex(&Sha256::digest(manifest.as_bytes())))); assert!(hash.starts_with(&hex(&Sha256::digest(manifest.as_bytes()))));
// Nothing missed, and the file says so
let mut exceptions = String::new();
std::io::Read::read_to_string(&mut zip.by_name("exceptions.csv").unwrap(), &mut exceptions).unwrap();
assert_eq!(exceptions, "path,account,kind,folder,date,archived,reason\n");
}
#[test]
fn what_cant_be_read_is_listed_not_dropped() {
let mut b = Builder::new();
b.add("[email protected]/mail/INBOX/1.eml".into(), b"Subject: x\r\n\r\ny", "[email protected]", "email", "INBOX", Some(0), false)
.unwrap();
b.missing("[email protected]/mail/INBOX/2.eml".into(), "[email protected]", "email", "INBOX", Some(0), false, NO_BLOB);
let (bytes, items) = b.finish().unwrap();
assert_eq!(items, 1, "a missing item isn't counted as collected");
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
assert!(zip.by_name("[email protected]/mail/INBOX/2.eml").is_err());
let mut exceptions = String::new();
std::io::Read::read_to_string(&mut zip.by_name("exceptions.csv").unwrap(), &mut exceptions).unwrap();
assert!(
exceptions.contains("[email protected]/mail/INBOX/2.eml,[email protected],email,INBOX,") && exceptions.contains(NO_BLOB),
"{exceptions}"
);
let mut hash = String::new();
std::io::Read::read_to_string(&mut zip.by_name("manifest.sha256").unwrap(), &mut hash).unwrap();
assert!(hash.contains(&format!("{} exceptions.csv", hex(&Sha256::digest(exceptions.as_bytes())))));
} }
} }
+28 -273
View File
@@ -2,8 +2,6 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use common::{ use common::{
@@ -15,8 +13,6 @@ use mail_auth::{
MX, RecordSet, MX, RecordSet,
common::resolver::ToFqdn, common::resolver::ToFqdn,
hickory_resolver::{ hickory_resolver::{
TokioResolver,
lookup::Lookup,
net::{DnsError, NetError}, net::{DnsError, NetError},
proto::{ proto::{
dnssec::Proof, dnssec::Proof,
@@ -87,15 +83,16 @@ impl TlsaLookup for Server {
return mail_auth::common::resolver::mock_resolve(key.as_ref()); return mail_auth::common::resolver::mock_resolve(key.as_ref());
} }
let (mx_lookup, forced_insecure) = match validated_lookup( let mx_lookup = match self
&self.core.smtp.resolvers.dnssec.resolver, .core
self.core.smtp.resolvers.dns.resolver(), .smtp
Name::from_str_relaxed::<&str>(key.as_ref())?, .resolvers
RecordType::MX, .dnssec
) .resolver
.mx_lookup(Name::from_str_relaxed::<&str>(key.as_ref())?)
.await .await
{ {
Ok(validated) => (validated.lookup, validated.insecure), Ok(mx_lookup) => mx_lookup,
Err(err) => { Err(err) => {
if let Some(denial) = NegativeAnswer::from_error(&err) if let Some(denial) = NegativeAnswer::from_error(&err)
&& denial.response_code == ResponseCode::NoError && denial.response_code == ResponseCode::NoError
@@ -147,11 +144,7 @@ impl TlsaLookup for Server {
.collect::<Arc<[MX]>>(); .collect::<Arc<[MX]>>();
let records = RecordSet { let records = RecordSet {
rrset, rrset,
dnssec_status: if forced_insecure { dnssec_status: dnssec_status.unwrap_or(DnssecStatus::Indeterminate),
DnssecStatus::Insecure
} else {
dnssec_status.unwrap_or(DnssecStatus::Indeterminate)
},
}; };
self.inner self.inner
@@ -292,15 +285,16 @@ impl TlsaLookup for Server {
} }
let name = Name::from_str_relaxed::<&str>(key.as_ref())?; let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
let (lookup, forced_insecure) = match validated_lookup( let lookup = match self
&self.core.smtp.resolvers.dnssec.resolver, .core
self.core.smtp.resolvers.dns.resolver(), .smtp
name.clone(), .resolvers
RecordType::A, .dnssec
) .resolver
.ipv4_lookup(name.clone())
.await .await
{ {
Ok(validated) => (validated.lookup, validated.insecure), Ok(lookup) => lookup,
Err(err) => { Err(err) => {
if let Some(denial) = NegativeAnswer::from_error(&err) if let Some(denial) = NegativeAnswer::from_error(&err)
&& denial.response_code == ResponseCode::NoError && denial.response_code == ResponseCode::NoError
@@ -331,11 +325,7 @@ impl TlsaLookup for Server {
_ => None, _ => None,
}) })
.collect::<Arc<[Ipv4Addr]>>(), .collect::<Arc<[Ipv4Addr]>>(),
dnssec_status: if forced_insecure { dnssec_status: tlsa_base_status(&name, answers, RecordType::A),
DnssecStatus::Insecure
} else {
tlsa_base_status(&name, answers, RecordType::A)
},
}; };
self.inner self.inner
@@ -373,15 +363,16 @@ impl TlsaLookup for Server {
} }
let name = Name::from_str_relaxed::<&str>(key.as_ref())?; let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
let (lookup, forced_insecure) = match validated_lookup( let lookup = match self
&self.core.smtp.resolvers.dnssec.resolver, .core
self.core.smtp.resolvers.dns.resolver(), .smtp
name.clone(), .resolvers
RecordType::AAAA, .dnssec
) .resolver
.ipv6_lookup(name.clone())
.await .await
{ {
Ok(validated) => (validated.lookup, validated.insecure), Ok(lookup) => lookup,
Err(err) => { Err(err) => {
if let Some(denial) = NegativeAnswer::from_error(&err) if let Some(denial) = NegativeAnswer::from_error(&err)
&& denial.response_code == ResponseCode::NoError && denial.response_code == ResponseCode::NoError
@@ -412,11 +403,7 @@ impl TlsaLookup for Server {
_ => None, _ => None,
}) })
.collect::<Arc<[Ipv6Addr]>>(), .collect::<Arc<[Ipv6Addr]>>(),
dnssec_status: if forced_insecure { dnssec_status: tlsa_base_status(&name, answers, RecordType::AAAA),
DnssecStatus::Insecure
} else {
tlsa_base_status(&name, answers, RecordType::AAAA)
},
}; };
self.inner self.inner
@@ -428,115 +415,6 @@ impl TlsaLookup for Server {
} }
} }
// inbuxa: hickory 0.26.3 calls some valid answers bogus, and the queue then
// retries those hosts until the message expires. Two cases seen in production:
//
// - A zone delegated beneath an unsigned zone, such as `l.google.com` under
// `google.com`. To prove the delegation insecure, hickory wants an SOA
// record in the DS reply, and public resolvers often send none.
// - A signed CNAME to a signed name without the record type queried. Hickory
// checks the denial of existence against the name first asked for, not the
// target's, and rejects it.
//
// When hickory says bogus, check the answer again with lookups it gets right.
// A signed CNAME is followed and the lookup repeated at its target. Otherwise
// the name's zone and its parents are looked up, nearest first. If one
// validates as unsigned, nothing below it can be signed, so the plain resolver
// answers and the result is insecure. If one validates as signed first, the
// verdict stands.
const MAX_BOGUS_ALIASES: usize = 8;
struct ValidatedLookup {
lookup: Lookup,
insecure: bool,
}
enum BogusRecheck {
Alias(Name),
Insecure,
Bogus,
}
async fn validated_lookup(
dnssec: &TokioResolver,
plain: &TokioResolver,
name: Name,
record_type: RecordType,
) -> Result<ValidatedLookup, NetError> {
let mut query = name;
let mut aliases = 0;
loop {
let err = match dnssec.lookup(query.clone(), record_type).await {
Ok(lookup) => {
return Ok(ValidatedLookup {
lookup,
insecure: false,
});
}
Err(err @ NetError::Dns(DnsError::DnssecBogus)) => err,
Err(err) => return Err(err),
};
match recheck_bogus(dnssec, &query).await {
BogusRecheck::Alias(target) if aliases < MAX_BOGUS_ALIASES => {
aliases += 1;
query = target;
}
BogusRecheck::Insecure => {
return plain
.lookup(query, record_type)
.await
.map(|lookup| ValidatedLookup {
lookup,
insecure: true,
});
}
BogusRecheck::Alias(_) | BogusRecheck::Bogus => return Err(err),
}
}
}
async fn recheck_bogus(dnssec: &TokioResolver, name: &Name) -> BogusRecheck {
if let Ok(lookup) = dnssec.lookup(name.clone(), RecordType::CNAME).await
&& let Some(target) = secure_alias(name, lookup.answers())
{
return BogusRecheck::Alias(target);
}
let mut zone = name.clone();
while !zone.is_root() {
if let Ok(lookup) = dnssec.lookup(zone.clone(), RecordType::SOA).await {
match apex_status(&zone, lookup.answers()) {
Some(DnssecStatus::Insecure) => return BogusRecheck::Insecure,
Some(DnssecStatus::Secure) => return BogusRecheck::Bogus,
_ => {}
}
}
zone = zone.base_name();
}
BogusRecheck::Bogus
}
fn secure_alias(query: &Name, answers: &[Record]) -> Option<Name> {
answers.iter().find_map(|record| match &record.data {
RData::CNAME(target) if &record.name == query && record.proof.is_secure() => {
Some(target.0.clone())
}
_ => None,
})
}
fn apex_status(zone: &Name, answers: &[Record]) -> Option<DnssecStatus> {
answers
.iter()
.filter(|record| record.record_type() == RecordType::SOA && &record.name == zone)
.map(|record| proof_to_dnssec_status(record.proof))
.reduce(least_secure)
}
struct NegativeAnswer { struct NegativeAnswer {
response_code: ResponseCode, response_code: ResponseCode,
dnssec_status: DnssecStatus, dnssec_status: DnssecStatus,
@@ -633,7 +511,7 @@ pub(crate) fn least_secure(a: DnssecStatus, b: DnssecStatus) -> DnssecStatus {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use mail_auth::hickory_resolver::proto::rr::rdata::{A, CNAME, SOA}; use mail_auth::hickory_resolver::proto::rr::rdata::{A, CNAME};
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
fn name(value: &str) -> Name { fn name(value: &str) -> Name {
@@ -746,127 +624,4 @@ mod tests {
DnssecStatus::Insecure DnssecStatus::Insecure
); );
} }
fn soa(owner: &str, proof: Proof) -> Record {
let mut record = Record::from_rdata(
name(owner),
3600,
RData::SOA(SOA::new(
name("ns1.example.org."),
name("hostmaster.example.org."),
1,
900,
900,
1800,
60,
)),
);
record.proof = proof;
record
}
#[test]
fn secure_alias_follows_signed_cname() {
assert_eq!(
secure_alias(
&name("mail.example.org."),
&[alias("mail.example.org.", "mx.example.net.", Proof::Secure)]
),
Some(name("mx.example.net."))
);
}
#[test]
fn secure_alias_ignores_unsigned_or_other_cname() {
let query = name("mail.example.org.");
assert_eq!(
secure_alias(
&query,
&[alias(
"mail.example.org.",
"mx.example.net.",
Proof::Insecure
)]
),
None
);
assert_eq!(
secure_alias(
&query,
&[alias(
"other.example.org.",
"mx.example.net.",
Proof::Secure
)]
),
None
);
}
#[test]
fn apex_status_reads_the_zone_soa() {
let zone = name("example.com.");
for (proof, expected) in [
(Proof::Secure, Some(DnssecStatus::Secure)),
(Proof::Insecure, Some(DnssecStatus::Insecure)),
(Proof::Bogus, Some(DnssecStatus::Bogus)),
] {
assert_eq!(
apex_status(&zone, &[soa("example.com.", proof)]),
expected,
"proof {proof}"
);
}
}
#[test]
fn apex_status_ignores_other_records() {
assert_eq!(
apex_status(
&name("example.com."),
&[
soa("sub.example.com.", Proof::Insecure),
address("example.com.", Proof::Insecure),
]
),
None
);
}
// Needs the network: a signed MX pointing into a zone delegated beneath an
// unsigned one. Run with `--ignored` to check a hickory upgrade.
#[tokio::test]
#[ignore]
async fn validated_lookup_proves_delegation_below_unsigned_zone() {
use mail_auth::hickory_resolver::{
config::{CLOUDFLARE, ResolverConfig, ResolverOpts},
net::runtime::TokioRuntimeProvider,
};
let build = |validate: bool| {
// Same options as the server's DNSSEC resolver; hickory fails
// validation with concurrent requests.
let mut opts = ResolverOpts::default();
opts.validate = validate;
opts.num_concurrent_reqs = 1;
opts.cache_size = 0;
TokioResolver::builder_with_config(
ResolverConfig::udp_and_tcp(&CLOUDFLARE),
TokioRuntimeProvider::default(),
)
.with_options(opts)
.build()
.unwrap()
};
let (dnssec, plain) = (build(true), build(false));
let validated =
validated_lookup(&dnssec, &plain, name("aspmx.l.google.com."), RecordType::A)
.await
.unwrap();
assert!(validated.insecure);
assert!(!validated.lookup.answers().is_empty());
}
} }
+2 -57
View File
@@ -26,10 +26,7 @@ use mail_auth::{
common::verify::VerifySignature, common::verify::VerifySignature,
dkim2::Dkim2Output, dkim2::Dkim2Output,
dmarc::{self}, dmarc::{self},
report::{ report::{AuthFailureType, IdentityAlignment, PolicyPublished, Record, SPFDomainScope},
ActionDisposition, AuthFailureType, IdentityAlignment, PolicyPublished, Record, Report,
SPFDomainScope,
},
}; };
use registry::{ use registry::{
schema::{ schema::{
@@ -462,7 +459,7 @@ impl DmarcReporting for Server {
.await .await
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_compact_string()); .unwrap_or_else(|| "MAILER-DAEMON@localhost".to_compact_string());
let mut message = Vec::with_capacity(2048); let mut message = Vec::with_capacity(2048);
let _ = with_compatible_dispositions(Report::from(report.report)).write_rfc5322( let _ = mail_auth::report::Report::from(report.report).write_rfc5322(
&self &self
.eval_if( .eval_if(
&self.core.smtp.report.submitter, &self.core.smtp.report.submitter,
@@ -713,55 +710,3 @@ impl DmarcReporting for Server {
} }
} }
} }
// inbuxa: RFC 9990 added "pass" to the evaluated disposition for mail that
// passed DMARC under an enforcing policy. Cloudflare's report intake rejects
// the whole report with "555 5.7.1 invalid_report_schema" when it sees that
// value, and older parsers built on the RFC 7489 schema do the same. "none"
// (no action taken) is valid under both and says the same thing, so reports
// go out with that instead.
fn with_compatible_dispositions(mut report: Report) -> Report {
for record in &mut report.record {
let disposition = &mut record.row.policy_evaluated.disposition;
if *disposition == ActionDisposition::Pass {
*disposition = ActionDisposition::None;
}
}
report
}
#[cfg(test)]
mod tests {
use super::*;
use mail_auth::report::DmarcResult;
fn record(disposition: ActionDisposition) -> Record {
Record::new()
.with_source_ip("192.0.2.1".parse().unwrap())
.with_count(1)
.with_action_disposition(disposition)
.with_dmarc_dkim_result(DmarcResult::Pass)
.with_dmarc_spf_result(DmarcResult::Fail)
.with_header_from("example.org")
}
#[test]
fn pass_disposition_is_reported_as_none() {
let xml = with_compatible_dispositions(
Report::new()
.with_domain("example.org")
.with_record(record(ActionDisposition::Pass))
.with_record(record(ActionDisposition::Quarantine))
.with_record(record(ActionDisposition::Reject)),
)
.to_xml();
assert!(!xml.contains("<disposition>pass</disposition>"), "{xml}");
assert!(xml.contains("<disposition>none</disposition>"), "{xml}");
assert!(
xml.contains("<disposition>quarantine</disposition>"),
"{xml}"
);
assert!(xml.contains("<disposition>reject</disposition>"), "{xml}");
}
}
+1 -1
View File
@@ -81,7 +81,7 @@ fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option<String> {
#[macro_export] #[macro_export]
macro_rules! brand_version { macro_rules! brand_version {
() => { () => {
"2026.9.28.2" "2026.9.28.1"
}; };
} }
Binary file not shown.
+1 -4
View File
@@ -2,8 +2,6 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{
@@ -176,8 +174,7 @@ async fn report_dmarc() {
let source_ip = record.source_ip().unwrap(); let source_ip = record.source_ip().unwrap();
if source_ip == "192.168.1.2".parse::<IpAddr>().unwrap() { if source_ip == "192.168.1.2".parse::<IpAddr>().unwrap() {
assert_eq!(record.count(), 2); assert_eq!(record.count(), 2);
// inbuxa: "pass" goes out as "none" for RFC 7489 parsers assert_eq!(record.action_disposition(), ActionDisposition::Pass);
assert_eq!(record.action_disposition(), ActionDisposition::None);
assert_eq!(record.envelope_from(), "[email protected]"); assert_eq!(record.envelope_from(), "[email protected]");
assert_eq!(record.header_from(), "[email protected]"); assert_eq!(record.header_from(), "[email protected]");
assert_eq!(record.envelope_to().unwrap(), "[email protected]"); assert_eq!(record.envelope_to().unwrap(), "[email protected]");
+7 -1
View File
@@ -494,7 +494,7 @@ pub async fn test(test: &mut TestServer) {
assert!( assert!(
names names
.iter() .iter()
.all(|n| n.starts_with("[email protected]/") || n.starts_with("manifest.")), .all(|n| n.starts_with("[email protected]/") || n.starts_with("manifest.") || n == "exceptions.csv"),
"LH-12: an account the hold doesn't cover was exported: {names:?}" "LH-12: an account the hold doesn't cover was exported: {names:?}"
); );
let mut manifest = String::new(); let mut manifest = String::new();
@@ -508,6 +508,12 @@ pub async fn test(test: &mut TestServer) {
.collect(); .collect();
assert!(hash.starts_with(&expected), "LH-12: the manifest's hash doesn't match"); assert!(hash.starts_with(&expected), "LH-12: the manifest's hash doesn't match");
assert!(manifest.contains(",true,"), "LH-12: nothing marked archived: {manifest}"); assert!(manifest.contains(",true,"), "LH-12: nothing marked archived: {manifest}");
let mut exceptions = String::new();
std::io::Read::read_to_string(&mut zip.by_name("exceptions.csv").unwrap(), &mut exceptions).unwrap();
assert_eq!(
exceptions, "path,account,kind,folder,date,archived,reason\n",
"LH-12: items the hold covers couldn't be read"
);
// LH-13: only sysLegalHoldExport starts one // LH-13: only sysLegalHoldExport starts one
let (_, response) = frozen let (_, response) = frozen
.hold_call( .hold_call(