Compare commits

...
16 Commits
Author SHA1 Message Date
jcoffey-dev de275bac60 Merge pull request 'Release 2026.9.28.3' (#80) from release-2026.9.28.3 into main
ci / fork-checks (push) Successful in 1m1s
publish / version (push) Successful in 56s
publish / publish-amd64 (push) Successful in 30m35s
publish / release (push) Successful in 6s
ci / build (push) Successful in 32m44s
publish / publish-arm64 (push) Successful in 36m4s
publish / binaries (push) Successful in 35s
publish / announce (push) Successful in 22s
2026-09-28 07:23:19 +00:00
jcoffey-dev 305406a331 Release 2026.9.28.3
ci / fork-checks (pull_request) Successful in 14s
ci / build (pull_request) Successful in 7m25s
Per-protocol legacy switches (#79) and the hold export's exceptions
list (#75). The prepared Explain answers are relabeled for this
release; 706 carry over unchanged.
2026-09-28 00:15:33 -07:00
jcoffey-dev f5888d79b0 Merge pull request 'Give IMAP, POP3 and ManageSieve a switch each' (#79) from feature/per-protocol-switches into main
ci / fork-checks (push) Successful in 38s
ci / build (push) Successful in 35m58s
2026-09-28 06:32:41 +00:00
jcoffey-dev 8e9cedbe97 Give IMAP, POP3 and ManageSieve a switch each
ci / fork-checks (pull_request) Successful in 43s
ci / build (pull_request) Successful in 7m40s
The legacy-protocols switch was all or nothing. An operator can now stop
POP3 and keep IMAP: each of IMAP, POP3 and ManageSieve has its own
switch, server-wide on inbuxa:ProtocolPolicy and per tenant on
inbuxa:TenantProtocolPolicy (properties imap, pop3, manageSieve).

legacyProtocols stays as the kill-all: setting it sets all three, and it
reads "disabled" exactly when all three are off. A policy stored before
this has only legacyProtocols and reads as all three at that value, so
existing servers and tenants carry over unchanged. In one /set, a
protocol named beside legacyProtocols overrides it.

SMTP submission keeps no switch of its own: sign-in over it is refused
only when all three are off, as the single switch did (LP-6), so
turning one protocol off never stops a mail app sending. For a tenant,
the server's switches and the tenant's count together.

Server-wide, a change closes the listeners of whatever is now off and
puts back the saved listeners of whatever is on again, both in one
change if asked; listeners of a protocol still off stay saved. Sign-in,
autoconfig, autodiscover, PACC (now prepared once per combination) and
the suggested DNS records all follow each protocol separately. A tenant
may turn a protocol on only while the server has it on (LP-9), and the
refusal names which. The JMAP session adds legacyAllowed, the protocols
still allowed for the account; legacyProtocols there keeps its meaning
for older webmail builds. Events name the switches ("pop3 disabled"),
and audit before/after reads every switch even from an older policy.

Tested: unit tests for the switches, the old-policy reading, the
server/tenant combination, the tenant refusal and listener refusal; and
tests/e2e/legacy_protocols.py against a running server, all 100 checks,
including new ones: POP3 alone off closes only its port and refuses
only its sign-in while IMAP and sending go on; only POP3 stops being
advertised; one change closes IMAP and reopens POP3; a tenant turns
POP3 off for itself, and can't turn IMAP on while the server has it off.
2026-09-27 23:12:32 -07:00
jcoffey-dev 5ba54e8fb7 Merge pull request 'List what a hold export can't read instead of skipping it (LH-12)' (#75) from fix/hold-export-exceptions into main
ci / fork-checks (push) Successful in 54s
ci / build (push) Canceled after 23m21s
Reviewed-on: #75
2026-09-28 06:09:20 +00:00
jcoffey-dev db817dd507 Merge pull request 'Release 2026.9.28.2' (#77) from release-2026.9.28.2 into main
publish / version (push) Successful in 31s
ci / fork-checks (push) Successful in 52s
ci / build (push) Canceled after 9m20s
publish / publish-amd64 (push) Successful in 34m25s
publish / release (push) Successful in 45s
publish / publish-arm64 (push) Successful in 36m5s
publish / binaries (push) Successful in 34s
publish / announce (push) Successful in 22s
2026-09-28 05:59:59 +00:00
jcoffey-dev b7e3a765ca Release 2026.9.28.2
ci / fork-checks (pull_request) Successful in 56s
ci / build (pull_request) Successful in 5m22s
2026-09-27 22:54:18 -07:00
jcoffey-dev 7ba9ec9fa0 Merge pull request 'Send "none" instead of "pass" as the DMARC report disposition' (#76) from fix/dmarc-disposition-compat into main
ci / build (push) Canceled after 11m35s
ci / fork-checks (push) Successful in 15s
2026-09-28 05:48:22 +00:00
jcoffey-dev 4c07779c16 Merge pull request 'Recheck DNSSEC lookups that hickory wrongly calls bogus' (#72) from fix/dnssec-insecure-fallback into main
ci / fork-checks (push) Successful in 2m2s
ci / build (push) Canceled after 14m59s
2026-09-28 05:33:21 +00:00
jcoffey-dev e1e8a9aeb0 Send "none" instead of "pass" as the DMARC report disposition
ci / build (pull_request) Successful in 16m34s
ci / fork-checks (pull_request) Successful in 52s
Cloudflare's DMARC report intake rejects every aggregate report we
send with "555 5.7.1 invalid_report_schema". Bisected against the live
endpoint: the only element it objects to is <disposition>pass</disposition>,
the value RFC 9990 added for mail that passed DMARC under an enforcing
policy. The RFC 9990 namespace, <np>, <discovery_method>, <testing> and
a missing <pct> are all accepted, and a report that differs only in
using "none" there goes through.

"none" (no action taken) is valid under both RFC 9990 and RFC 7489 and
says the same thing to the reader, so reports now go out with it. The
stored report keeps "pass"; only the serialized copy changes.
2026-09-27 22:31:13 -07:00
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
jcoffey-dev 3978cf5785 Merge pull request 'Release 2026.9.28.1' (#74) from release-2026.9.28.1 into main
ci / build (push) Canceled after 29m44s
ci / fork-checks (push) Successful in 14s
publish / version (push) Successful in 32s
publish / publish-amd64 (push) Successful in 28m55s
publish / release (push) Successful in 15s
publish / publish-arm64 (push) Successful in 1h2m48s
publish / binaries (push) Successful in 51s
publish / announce (push) Successful in 23s
2026-09-28 05:03:38 +00:00
jcoffey-dev 815a642cc4 Release 2026.9.28.1
ci / fork-checks (pull_request) Successful in 16s
ci / build (pull_request) Successful in 7m29s
Legal hold exports (LH-12, #73). The prepared Explain answers are
relabeled for this release; 706 carry over unchanged.
2026-09-27 21:55:55 -07:00
jcoffey-dev 1d5f4a2cd3 Merge pull request 'Export what a legal hold keeps as a ZIP (LH-12)' (#73) from feature/hold-export into main
ci / fork-checks (push) Successful in 50s
ci / build (push) Canceled after 12m21s
2026-09-28 04:51:16 +00:00
jcoffey-dev 68dd749291 Export what a legal hold keeps as a ZIP (LH-12)
ci / build (pull_request) Successful in 4m47s
ci / fork-checks (pull_request) Successful in 14s
inbuxa:HoldExport/set takes a hold, optionally some of the accounts it
covers, and a reason; the collection runs in the background and get
says when it's ready. The ZIP has, per account, mail as .eml under its
folders, calendars as .ics, contacts as .vcf, files as stored, and the
archived items the hold keeps under archived/; a manifest.csv gives each
entry's account, kind, folder, date, whether it was archived, size and
SHA-256, and manifest.sha256 hashes the manifest. Accounts the hold
doesn't cover are left out, and items outside its date range are too:
live mail by arrival, events by start, and archived items the same way,
so an export doesn't carry deleted items that only another hold keeps.

The finished file is a blob of whoever started the export, so only they
download it, and it lasts as long as any upload (uploadTtl). Exports
are records under the hold (SUBSPACE_INBUXA H/e): never changed or
destroyed, each with its status, counts, size and checksum. Starting
one needs sysLegalHoldExport, an active hold and a reason, and is
recorded in the audit log like the audit log's own export.

The build is in memory and capped at 2 GB; bigger holds fail with a
message saying so, and are split by picking accounts.

Tested: unit tests for safe ZIP names and the manifest and its hash;
the legal_hold system test, on RocksDB, PostgreSQL and MySQL, exports a
hold end to end (live and archived mail, the manifest's hash, an asked-
for account the hold doesn't cover left out) and checks the refusals
(no reason, a user without the permission, a released hold) and the
audit record; and by hand from the console on a local server. Not
covered by a test: the archived-item date range with two holds of
different ranges over one account.
2026-09-27 21:45:52 -07:00
jcoffey-dev b1bc5ed6e0 Recheck DNSSEC lookups that hickory wrongly calls bogus
ci / fork-checks (pull_request) Successful in 14s
ci / build (pull_request) Successful in 7m34s
hickory 0.26.3 rejects two kinds of valid answers, and outbound
delivery then retries those hosts until the message expires:

- A zone delegated beneath an unsigned zone (l.google.com under
  google.com). Proving the delegation insecure needs an SOA record in
  the DS reply, and public resolvers often leave it out. Every Google
  MX host behind a signed MX record was unreachable.
- A signed CNAME to a signed name that lacks the queried type. The
  NSEC denial is checked against the original name, not the target's.

On a bogus verdict, follow a signed CNAME and repeat the lookup at its
target; otherwise look up the name's zone and its parents, nearest
first. A zone that validates as unsigned means nothing below it can be
signed, so the plain resolver answers and the result is insecure. A
zone that validates as signed first leaves the verdict standing.
2026-09-27 21:45:13 -07:00
39 changed files with 2628 additions and 235 deletions
Generated
+2
View File
@@ -4258,6 +4258,7 @@ dependencies = [
"tungstenite 0.30.0", "tungstenite 0.30.0",
"types", "types",
"utils", "utils",
"zip",
] ]
[[package]] [[package]]
@@ -8624,6 +8625,7 @@ dependencies = [
"types", "types",
"utils", "utils",
"x509-parser", "x509-parser",
"zip",
] ]
[[package]] [[package]]
+25 -15
View File
@@ -46,10 +46,10 @@ pub struct Network {
#[derive(Clone)] #[derive(Clone)]
pub struct NetworkInfo { pub struct NetworkInfo {
pub pacc: Pacc, /// inbuxa: the document once per combination of legacy protocols off,
/// inbuxa: the same document without IMAP, POP3, SMTP and ManageSieve, /// indexed by `LegacyOff::index` (legacy-protocols LP-7, one switch per
/// served while legacy protocols are off (legacy-protocols LP-7). /// protocol); index 0 is the full document.
pub pacc_jmap_only: Pacc, pub pacc: Vec<Pacc>,
pub mxs: Vec<MailExchanger>, pub mxs: Vec<MailExchanger>,
pub services: VecMap<ServiceProtocol, Service>, pub services: VecMap<ServiceProtocol, Service>,
} }
@@ -333,16 +333,27 @@ impl Network {
}) })
.unwrap() .unwrap()
}; };
// inbuxa: legacy-protocols LP-7 // inbuxa: legacy-protocols LP-7, one document per combination of
let pacc_jmap_only = { // protocols off, bits as `LegacyOff::index`: IMAP, POP3, ManageSieve,
let mut pacc = pacc.clone(); // submission.
pacc.protocols.imap = None; let pacc = (0..16usize)
pacc.protocols.pop3 = None; .map(|off| {
pacc.protocols.smtp = None; let mut pacc = pacc.clone();
pacc.protocols.managesieve = None; if off & 1 != 0 {
split(&pacc) pacc.protocols.imap = None;
}; }
let pacc = split(&pacc); if off & 2 != 0 {
pacc.protocols.pop3 = None;
}
if off & 4 != 0 {
pacc.protocols.managesieve = None;
}
if off & 8 != 0 {
pacc.protocols.smtp = None;
}
split(&pacc)
})
.collect();
let mut network = Network { let mut network = Network {
node_id: bp.node_id() as u64, node_id: bp.node_id() as u64,
server_name: default_hostname.to_string(), server_name: default_hostname.to_string(),
@@ -358,7 +369,6 @@ impl Network {
mxs: system.mail_exchangers.into_iter().collect(), mxs: system.mail_exchangers.into_iter().collect(),
services: system.services, services: system.services,
pacc, pacc,
pacc_jmap_only,
}, },
}; };
@@ -6,7 +6,7 @@
* Modified by Coffey Labs in 2026 for INBUXA. * Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service}; use crate::{Server, manager::application::Resource};
use quick_xml::Reader; use quick_xml::Reader;
use quick_xml::XmlVersion; use quick_xml::XmlVersion;
use quick_xml::events::Event; use quick_xml::events::Event;
@@ -59,11 +59,11 @@ impl Server {
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>"); let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
// inbuxa: legacy-protocols LP-7, LP-14a // inbuxa: legacy-protocols LP-7, LP-14a
let legacy_off = match emailaddress.rsplit_once('@') { let legacy_off = match emailaddress.rsplit_once('@') {
Some((_, domain)) => self.legacy_protocols_off_for(domain).await?, Some((_, domain)) => self.legacy_off_for(domain).await?,
None => self.legacy_protocols_off_for("").await?, None => self.legacy_off_for("").await?,
}; };
for (protocol, service) in &self.core.network.info.services { for (protocol, service) in &self.core.network.info.services {
if legacy_off && is_legacy_service(protocol) { if legacy_off.service(protocol) {
continue; continue;
} }
let (protocol, ports) = match protocol { let (protocol, ports) = match protocol {
@@ -6,7 +6,7 @@
* Modified by Coffey Labs in 2026 for INBUXA. * Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service}; use crate::{Server, manager::application::Resource};
use registry::schema::enums::ServiceProtocol; use registry::schema::enums::ServiceProtocol;
use std::fmt::Write; use std::fmt::Write;
use utils::url_params::UrlParams; use utils::url_params::UrlParams;
@@ -31,7 +31,7 @@ impl Server {
}; };
// inbuxa: legacy-protocols LP-7, LP-14a // inbuxa: legacy-protocols LP-7, LP-14a
let legacy_off = self.legacy_protocols_off_for(domain).await?; let legacy_off = self.legacy_off_for(domain).await?;
// Build XML response // Build XML response
let mut config = String::with_capacity(1024); let mut config = String::with_capacity(1024);
@@ -45,7 +45,7 @@ impl Server {
"\t\t<displayShortName>{domain}</displayShortName>" "\t\t<displayShortName>{domain}</displayShortName>"
); );
for (protocol, service) in &self.core.network.info.services { for (protocol, service) in &self.core.network.info.services {
if legacy_off && is_legacy_service(protocol) { if legacy_off.service(protocol) {
continue; continue;
} }
let (protocol, tag, ports) = match protocol { let (protocol, tag, ports) = match protocol {
+7 -14
View File
@@ -6,11 +6,7 @@
* Modified by Coffey Labs in 2026 for INBUXA. * Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{Server, config::network::Pacc, network::dkim::generate_dkim_dns_record};
Server,
config::network::Pacc,
network::{dkim::generate_dkim_dns_record, legacy::is_legacy_service},
};
use ahash::{AHashMap, AHashSet}; use ahash::{AHashMap, AHashSet};
use base64::{Engine, engine::general_purpose}; use base64::{Engine, engine::general_purpose};
use dns_update::{ use dns_update::{
@@ -41,7 +37,7 @@ impl Server {
let default_host = network.server_name.as_str(); let default_host = network.server_name.as_str();
let domain_name = domain.name.as_str(); let domain_name = domain.name.as_str();
// inbuxa: legacy-protocols LP-7, LP-14a // inbuxa: legacy-protocols LP-7, LP-14a
let legacy_off = self.legacy_protocols_off_for(domain_name).await?; let legacy_off = self.legacy_off_for(domain_name).await?;
let domain_name_suffix = format!(".{domain_name}"); let domain_name_suffix = format!(".{domain_name}");
for record_type in record_types { for record_type in record_types {
@@ -205,7 +201,7 @@ impl Server {
// name says "not offered" -- target "." (RFC 6186 section // name says "not offered" -- target "." (RFC 6186 section
// 3.4) -- rather than vanishing, so a client that looks // 3.4) -- rather than vanishing, so a client that looks
// is told, and an old record left in the zone is replaced. // is told, and an old record left in the zone is replaced.
if legacy_off && is_legacy_service(protocol) { if legacy_off.service(protocol) {
for (service_name, _) in services { for (service_name, _) in services {
records.push(NamedDnsRecord { records.push(NamedDnsRecord {
name: format!("_{service_name}._tcp.{domain_name}."), name: format!("_{service_name}._tcp.{domain_name}."),
@@ -307,8 +303,8 @@ impl Server {
// inbuxa: legacy-protocols LP-7. No TLS pin for a port // inbuxa: legacy-protocols LP-7. No TLS pin for a port
// the switch has closed. Submission's port stays open // the switch has closed. Submission's port stays open
// (the SMTP lock), so its record stays. // (the SMTP lock), so its record stays.
if legacy_off if matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3)
&& matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3) && legacy_off.service(protocol)
{ {
continue; continue;
} }
@@ -418,11 +414,8 @@ impl Server {
pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> { pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> {
// inbuxa: legacy-protocols LP-7, LP-14a // inbuxa: legacy-protocols LP-7, LP-14a
let pacc = if self.legacy_protocols_off_for(domain_name).await? { let off = self.legacy_off_for(domain_name).await?;
&self.core.network.info.pacc_jmap_only let pacc = &self.core.network.info.pacc[off.index()];
} else {
&self.core.network.info.pacc
};
self.get_directory_for_domain(domain_name) self.get_directory_for_domain(domain_name)
.await .await
.caused_by(trc::location!()) .caused_by(trc::location!())
+191 -63
View File
@@ -35,8 +35,8 @@ use directory::Credentials;
use inbuxa_features::security::{ use inbuxa_features::security::{
legacy_use::{self, LegacyUse}, legacy_use::{self, LegacyUse},
listeners, listeners,
protocol_policy::{self, ProtocolPolicy, SavedListener}, protocol_policy::{self, ProtocolPolicy, SUBMISSION, SWITCHED, SavedListener, Switches},
tenant_protocol_policy, tenant_protocol_policy::{self, OffBy, TenantProtocolPolicy},
}; };
use registry::schema::enums::ServiceProtocol; use registry::schema::enums::ServiceProtocol;
use registry::types::{error::Error, id::ObjectId}; use registry::types::{error::Error, id::ObjectId};
@@ -97,40 +97,48 @@ impl Server {
// this, and a /set that omitted it must not lose the listeners still // this, and a /set that omitted it must not lose the listeners still
// waiting to come back. // waiting to come back.
let previous = self.protocol_policy().await?; let previous = self.protocol_policy().await?;
policy.saved_listeners = previous.saved_listeners; policy.saved_listeners = previous.saved_listeners.clone();
policy.changed_at = Some(store::write::now() * 1000); policy.changed_at = Some(store::write::now() * 1000);
policy.changed_by = changed_by; policy.changed_by = changed_by;
policy.normalize();
if policy.legacy_protocols.is_disabled() { // Each protocol on its own switch: close what is off now, and put
self.close_legacy_listeners(&mut policy, &mut change).await?; // back what was saved for a protocol that is on again. Either may
} else { // happen in one change, when one protocol goes off as another comes
self.reopen_legacy_listeners(&mut policy, &mut change) // back.
.await?; self.close_legacy_listeners(&mut policy, &mut change)
} .await?;
self.reopen_legacy_listeners(&mut policy, &mut change)
.await?;
protocol_policy::set(&self.core.storage.data, &policy).await?; protocol_policy::set(&self.core.storage.data, &policy).await?;
// LP-8. Raised here rather than by the JMAP method, so whatever turns // LP-8. Raised here rather than by the JMAP method, so whatever turns
// the switch is reported. A /set that changed nothing -- the switch // a switch is reported. A /set that changed nothing -- every switch
// already where it was asked to be, nothing to close or reopen -- is // already where it was asked to be, nothing to close or reopen -- is
// not a change. // not a change.
if previous.legacy_protocols != policy.legacy_protocols || !change.is_empty() { let mut before = previous;
let (moved, direction) = if policy.legacy_protocols.is_disabled() { before.normalize();
(&change.closed, "closed") if before.off() != policy.off() || !change.is_empty() {
} else { // The closed first, then the reopened; `Details` says which.
(&change.reopened, "reopened") let moved = change
}; .closed
.iter()
.chain(change.reopened.iter())
.map(|l| l.id.clone());
trc::event!( trc::event!(
Security(trc::SecurityEvent::LegacyProtocolsChanged), Security(trc::SecurityEvent::LegacyProtocolsChanged),
Policy = "server", Policy = "server",
Value = if policy.legacy_protocols.is_disabled() { Value = switches_value(&policy),
"disabled"
} else {
"enabled"
},
AccountId = policy.changed_by.clone(), AccountId = policy.changed_by.clone(),
Details = direction, Details = if change.closed.is_empty() {
ListenerId = listener_names(moved.iter().map(|l| l.id.clone())), "reopened"
} else if change.reopened.is_empty() {
"closed"
} else {
"closed and reopened"
},
ListenerId = listener_names(moved),
// Only when a listener could not be put back (LP-5). // Only when a listener could not be put back (LP-5).
Reason = (!change.failed.is_empty()).then(|| listener_names( Reason = (!change.failed.is_empty()).then(|| listener_names(
change change
@@ -165,21 +173,27 @@ impl Server {
Ok(()) Ok(())
} }
/// Puts back every saved listener and starts it again (LP-5). /// Puts back every saved listener whose protocol is on again, and starts
/// it (LP-5). The rest stay saved.
async fn reopen_legacy_listeners( async fn reopen_legacy_listeners(
&self, &self,
policy: &mut ProtocolPolicy, policy: &mut ProtocolPolicy,
change: &mut PolicyChange, change: &mut PolicyChange,
) -> trc::Result<()> { ) -> trc::Result<()> {
if policy.saved_listeners.is_empty() { let (wanted, still_closed): (Vec<_>, Vec<_>) = std::mem::take(&mut policy.saved_listeners)
.into_iter()
.partition(|saved| !policy.closes(&saved.protocol, &saved.ports));
policy.saved_listeners = still_closed;
if wanted.is_empty() {
return Ok(()); return Ok(());
} }
let saved = std::mem::take(&mut policy.saved_listeners); let (restored, failed) = listeners::reopen(self.registry(), &wanted).await?;
let (restored, failed) = listeners::reopen(self.registry(), &saved).await?;
// A listener that could not be put back stays saved for another try. // A listener that could not be put back stays saved for another try.
policy.saved_listeners = failed.iter().map(|(listener, _)| listener.clone()).collect(); policy
.saved_listeners
.extend(failed.iter().map(|(listener, _)| listener.clone()));
change.failed = failed; change.failed = failed;
if !restored.is_empty() { if !restored.is_empty() {
@@ -254,6 +268,17 @@ impl Server {
} }
} }
/// The switches as an event value: `disabled` or `enabled` when all three
/// agree, otherwise which are off, such as `pop3 disabled` (LP-8).
pub fn switches_value(policy: &impl Switches) -> String {
let off = policy.off();
match off.len() {
0 => "enabled".to_string(),
n if n == SWITCHED.len() => "disabled".to_string(),
_ => format!("{} disabled", off.join(", ")),
}
}
/// Names for an event field: the listeners a change closed, reopened or /// Names for an event field: the listeners a change closed, reopened or
/// failed to reopen (LP-8). /// failed to reopen (LP-8).
fn listener_names<T: Into<trc::Value>>(names: impl Iterator<Item = T>) -> trc::Value { fn listener_names<T: Into<trc::Value>>(names: impl Iterator<Item = T>) -> trc::Value {
@@ -395,15 +420,18 @@ impl Server {
credentials: &Credentials, credentials: &Credentials,
) -> trc::Result<()> { ) -> trc::Result<()> {
let domain = domain_of(credentials); let domain = domain_of(credentials);
if self.protocol_policy().await?.legacy_protocols.is_disabled() { let server = self.protocol_policy().await?;
if server.is_off(protocol.as_str()) {
return Err(protocol.refused(RefusalScope::Server, domain)); return Err(protocol.refused(RefusalScope::Server, domain));
} }
if let Some(name) = &domain if let Some(name) = &domain
&& let Some(domain) = self.domain(name).await? && let Some(domain) = self.domain(name).await?
&& let Some(tenant_id) = domain.id_tenant && let Some(tenant_id) = domain.id_tenant
&& self.tenant_legacy_protocols_off(tenant_id).await?
{ {
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), Some(name.clone()))); let tenant = self.tenant_protocol_policy(tenant_id).await?;
if tenant_protocol_policy::off_by(&server, Some(&tenant), protocol.as_str()).is_some() {
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), Some(name.clone())));
}
} }
Ok(()) Ok(())
} }
@@ -422,10 +450,16 @@ impl Server {
protocol: LegacyProtocol, protocol: LegacyProtocol,
access_token: &AccessToken, access_token: &AccessToken,
) -> trc::Result<()> { ) -> trc::Result<()> {
if let Some(tenant_id) = access_token.tenant_id() if let Some(tenant_id) = access_token.tenant_id() {
&& self.tenant_legacy_protocols_off(tenant_id).await? let server = self.protocol_policy().await?;
{ let tenant = self.tenant_protocol_policy(tenant_id).await?;
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), None)); match tenant_protocol_policy::off_by(&server, Some(&tenant), protocol.as_str()) {
Some(OffBy::Server) => return Err(protocol.refused(RefusalScope::Server, None)),
Some(OffBy::Tenant) => {
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), None));
}
None => {}
}
} }
if let Err(err) = legacy_use::record( if let Err(err) = legacy_use::record(
&self.core.storage.data, &self.core.storage.data,
@@ -463,31 +497,96 @@ impl Server {
Ok(recent) Ok(recent)
} }
/// Whether legacy protocols are off for this account: the stricter of the /// Which legacy protocols are off for this account: each the stricter of
/// server's switch and its tenant's. What the JMAP session tells the /// the server's switch and its tenant's. What the JMAP session tells the
/// account's apps (legacy-protocols spec, Interfaces), so the webmail can /// account's apps (legacy-protocols spec, Interfaces), so the webmail can
/// say why a mail app won't connect (LP-19). /// say why a mail app won't connect (LP-19).
pub async fn legacy_protocols_off_for_account( pub async fn legacy_off_for_account(
&self, &self,
access_token: &AccessToken, access_token: &AccessToken,
) -> trc::Result<bool> { ) -> trc::Result<LegacyOff> {
if self.protocol_policy().await?.legacy_protocols.is_disabled() { let server = self.protocol_policy().await?;
return Ok(true); let tenant = match access_token.tenant_id() {
} Some(tenant_id) => Some(self.tenant_protocol_policy(tenant_id).await?),
match access_token.tenant_id() { None => None,
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await, };
None => Ok(false), Ok(LegacyOff::of(&server, tenant.as_ref()))
}
/// A tenant's switches, or all on when it has never set them (LP-10).
pub async fn tenant_protocol_policy(
&self,
tenant_id: u32,
) -> trc::Result<TenantProtocolPolicy> {
tenant_protocol_policy::get(&self.core.storage.data, tenant_id).await
}
}
/// Which legacy protocols are off, for one account or one domain: the server's
/// switches and the tenant's together. Submission is off only when all three
/// are.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct LegacyOff {
pub imap: bool,
pub pop3: bool,
pub manage_sieve: bool,
pub submission: bool,
}
impl LegacyOff {
pub fn of(server: &ProtocolPolicy, tenant: Option<&TenantProtocolPolicy>) -> Self {
let off = |protocol| tenant_protocol_policy::off_by(server, tenant, protocol).is_some();
LegacyOff {
imap: off("imap"),
pop3: off("pop3"),
manage_sieve: off("manageSieve"),
submission: off(SUBMISSION),
} }
} }
/// Whether a tenant has turned legacy protocols off for itself (LP-10). /// Whether this configured service must not be offered (LP-7). SMTP here
pub async fn tenant_legacy_protocols_off(&self, tenant_id: u32) -> trc::Result<bool> { /// is submission; inbound mail is never a configured service.
Ok( pub fn service(&self, protocol: &ServiceProtocol) -> bool {
tenant_protocol_policy::get(&self.core.storage.data, tenant_id) match protocol {
.await? ServiceProtocol::Imap => self.imap,
.legacy_protocols ServiceProtocol::Pop3 => self.pop3,
.is_disabled(), ServiceProtocol::Managesieve => self.manage_sieve,
) ServiceProtocol::Smtp => self.submission,
_ => false,
}
}
/// Whether anything is off.
pub fn any(&self) -> bool {
self.imap || self.pop3 || self.manage_sieve || self.submission
}
/// Whether everything is off: the kill-all's effect.
pub fn all(&self) -> bool {
self.imap && self.pop3 && self.manage_sieve && self.submission
}
/// An index for answers prepared once per combination (the PACC
/// document): one bit per protocol.
pub fn index(&self) -> usize {
(self.imap as usize)
| (self.pop3 as usize) << 1
| (self.manage_sieve as usize) << 2
| (self.submission as usize) << 3
}
/// The protocols that are still allowed, by JMAP name, for the session.
pub fn allowed(&self) -> Vec<&'static str> {
[
("imap", self.imap),
("pop3", self.pop3),
("manageSieve", self.manage_sieve),
(SUBMISSION, self.submission),
]
.into_iter()
.filter(|(_, off)| !off)
.map(|(name, _)| name)
.collect()
} }
} }
@@ -505,21 +604,20 @@ pub fn is_legacy_service(protocol: &ServiceProtocol) -> bool {
} }
impl Server { impl Server {
/// Whether legacy services are off for this domain, for the answers that /// Which legacy services are off for this domain, for the answers that
/// must stop offering them: off for the whole server (LP-7), or for the /// must stop offering them: off for the whole server (LP-7), or for the
/// tenant the domain belongs to (LP-14a). Read per answer, as sign-in /// tenant the domain belongs to (LP-14a). Read per answer, as sign-in
/// reads it. A name that is no domain here answers for the server alone. /// reads it. A name that is no domain here answers for the server alone.
pub async fn legacy_protocols_off_for(&self, domain_name: &str) -> trc::Result<bool> { pub async fn legacy_off_for(&self, domain_name: &str) -> trc::Result<LegacyOff> {
if self.protocol_policy().await?.legacy_protocols.is_disabled() { let server = self.protocol_policy().await?;
return Ok(true); let tenant = match self.domain(domain_name).await? {
}
match self.domain(domain_name).await? {
Some(domain) => match domain.id_tenant { Some(domain) => match domain.id_tenant {
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await, Some(tenant_id) => Some(self.tenant_protocol_policy(tenant_id).await?),
None => Ok(false), None => None,
}, },
None => Ok(false), None => None,
} };
Ok(LegacyOff::of(&server, tenant.as_ref()))
} }
} }
@@ -619,6 +717,36 @@ mod tests {
} }
} }
#[test]
fn what_is_off_for_one_account_or_domain() {
use inbuxa_features::security::protocol_policy::LegacyProtocols;
let mut server = ProtocolPolicy::default();
server.set("pop3", LegacyProtocols::Disabled);
let mut tenant = TenantProtocolPolicy::default();
tenant.set("manageSieve", LegacyProtocols::Disabled);
let off = LegacyOff::of(&server, Some(&tenant));
assert!(off.pop3 && off.manage_sieve && !off.imap && !off.submission);
assert!(off.service(&ServiceProtocol::Pop3));
assert!(!off.service(&ServiceProtocol::Imap));
assert!(
!off.service(&ServiceProtocol::Smtp),
"sending is still offered"
);
assert!(!off.service(&ServiceProtocol::Jmap));
assert_eq!(off.allowed(), vec!["imap", "submission"]);
assert!(off.any() && !off.all());
let off = LegacyOff::of(&server, None);
assert_eq!(off.index(), 0b0010);
server.set_all(LegacyProtocols::Disabled);
let off = LegacyOff::of(&server, None);
assert!(off.all());
assert_eq!(off.index(), 0b1111);
assert!(off.allowed().is_empty());
}
#[test] #[test]
fn the_domain_comes_from_the_name_given() { fn the_domain_comes_from_the_name_given() {
assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string())); assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string()));
+103
View File
@@ -108,6 +108,45 @@ impl Keeping {
const FEATURE: u8 = b'H'; const FEATURE: u8 = b'H';
const KIND_HOLD: u8 = b'h'; const KIND_HOLD: u8 = b'h';
const KIND_ORIGINAL: u8 = b'o'; const KIND_ORIGINAL: u8 = b'o';
const KIND_EXPORT: u8 = b'e';
/// How far a hold export has got (LH-12).
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub enum ExportStatus {
Running,
Ready,
Failed,
}
/// A collection of what a hold keeps, as a ZIP (LH-12).
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct Export {
pub id: u32,
pub hold_id: u32,
/// The accounts asked for; empty for every account the hold covers.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub accounts: Vec<u32>,
pub reason: String,
pub created_at: u64,
pub created_by: String,
/// Whose blob the ZIP is, so only they download it.
pub created_by_id: u32,
pub status: ExportStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finished_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blob_id: Option<String>,
#[serde(default)]
pub size: u64,
#[serde(default)]
pub items: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// How many times creating a hold retries when another node took its id. /// How many times creating a hold retries when another node took its id.
const CREATE_ATTEMPTS: usize = 5; const CREATE_ATTEMPTS: usize = 5;
@@ -402,6 +441,70 @@ pub async fn set_original_deadline(data: &Store, item_id: u64, until: Option<u64
.map(|_| ()) .map(|_| ())
} }
fn export_class(id: u32) -> ValueClass {
let mut key = Vec::with_capacity(6);
key.push(FEATURE);
key.push(KIND_EXPORT);
key.extend_from_slice(&id.to_be_bytes());
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
})
}
/// Every hold export, oldest first.
pub async fn exports(data: &Store) -> trc::Result<Vec<Export>> {
let mut exports = Vec::new();
data.iterate(
IterateParams::new(ValueKey::from(export_class(0)), ValueKey::from(export_class(u32::MAX))),
|_, value| {
if let Ok(Json(export)) = Json::<Export>::deserialize(value) {
exports.push(export);
}
Ok(true)
},
)
.await
.caused_by(trc::location!())?;
Ok(exports)
}
/// Writes a new export under the next free id, which it returns.
pub async fn create_export(data: &Store, export: &Export) -> trc::Result<u32> {
let mut attempt = 0;
loop {
attempt += 1;
let id = exports(data).await?.iter().map(|e| e.id).max().unwrap_or(0) + 1;
let stored = Export {
id,
..export.clone()
};
let mut batch = BatchBuilder::new();
batch.assert_value(export_class(id), AssertValue::None);
batch.set(export_class(id), Json(&stored).serialize()?);
match data.write(batch.build_all()).await {
Ok(_) => return Ok(id),
Err(err)
if attempt < CREATE_ATTEMPTS
&& matches!(
err.as_ref(),
trc::EventType::Store(trc::StoreEvent::AssertValueFailed)
) => {}
Err(err) => return Err(err.caused_by(trc::location!())),
}
}
}
/// Saves an export's progress.
pub async fn update_export(data: &Store, export: &Export) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(export_class(export.id), Json(export).serialize()?);
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
/// One hold, released or not. /// One hold, released or not.
pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Hold>> { pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Hold>> {
Ok(data Ok(data
+229 -11
View File
@@ -8,6 +8,17 @@
//! (legacy-protocols spec, data model and LP-1 to LP-8). Stored as JSON under //! (legacy-protocols spec, data model and LP-1 to LP-8). Stored as JSON under
//! `P` + `p` in the fork's subspace; unset fields read as the defaults. //! `P` + `p` in the fork's subspace; unset fields read as the defaults.
//! //!
//! Each mail-app protocol has its own switch (legacy-protocols spec,
//! "Revisit: one switch per protocol"): IMAP, POP3 and ManageSieve.
//! `legacyProtocols` is the kill-all: setting it sets all three, and it reads
//! `disabled` exactly when all three are off. A policy stored before the
//! per-protocol switches has only `legacyProtocols`, and reads as all three
//! at that value.
//!
//! SMTP submission has no switch of its own here: sign-in over it is refused
//! only when all three are off, as it was by the single switch (LP-6), so
//! turning off one protocol never stops a mail app sending.
//!
//! This module is the fact, not the act. It holds what the operator chose and //! This module is the fact, not the act. It holds what the operator chose and
//! which listeners were taken away to honour it. Closing sockets belongs to //! which listeners were taken away to honour it. Closing sockets belongs to
//! `common`, which owns the listener registry, and removing the listener //! `common`, which owns the listener registry, and removing the listener
@@ -59,12 +70,30 @@ pub struct SavedListener {
pub object: serde_json::Value, pub object: serde_json::Value,
} }
/// The server-wide switch. /// The protocols with a switch of their own, as the schema and JMAP spell
/// them.
pub const SWITCHED: &[&str] = &["imap", "pop3", "manageSieve"];
/// The name sign-in uses for SMTP AUTH, which follows the kill-all.
pub const SUBMISSION: &str = "submission";
/// The server-wide switches.
#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)] #[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)] #[serde(rename_all = "camelCase", default)]
pub struct ProtocolPolicy { pub struct ProtocolPolicy {
/// The switch itself. /// The kill-all: `disabled` exactly when all three protocols are off,
/// once [`ProtocolPolicy::normalize`] has run. In a policy stored before
/// the per-protocol switches, it is the value of all three.
pub legacy_protocols: LegacyProtocols, pub legacy_protocols: LegacyProtocols,
/// IMAP's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub imap: Option<LegacyProtocols>,
/// POP3's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub pop3: Option<LegacyProtocols>,
/// ManageSieve's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub manage_sieve: Option<LegacyProtocols>,
/// With `disabled`, also close SMTP submission (LP-3). The inbound /// With `disabled`, also close SMTP submission (LP-3). The inbound
/// listener on port 25 is never closed, whatever this says. /// listener on port 25 is never closed, whatever this says.
pub close_submission: bool, pub close_submission: bool,
@@ -80,6 +109,9 @@ impl Default for ProtocolPolicy {
fn default() -> Self { fn default() -> Self {
ProtocolPolicy { ProtocolPolicy {
legacy_protocols: LegacyProtocols::Enabled, legacy_protocols: LegacyProtocols::Enabled,
imap: None,
pop3: None,
manage_sieve: None,
close_submission: true, close_submission: true,
saved_listeners: Vec::new(), saved_listeners: Vec::new(),
changed_at: None, changed_at: None,
@@ -91,6 +123,9 @@ impl Default for ProtocolPolicy {
/// The properties `inbuxa:ProtocolPolicy` has, as they appear over JMAP. /// The properties `inbuxa:ProtocolPolicy` has, as they appear over JMAP.
pub const PROPERTIES: &[&str] = &[ pub const PROPERTIES: &[&str] = &[
"legacyProtocols", "legacyProtocols",
"imap",
"pop3",
"manageSieve",
"closeSubmission", "closeSubmission",
"savedListeners", "savedListeners",
"changedAt", "changedAt",
@@ -129,23 +164,137 @@ pub fn is_locked(protocol: &str) -> bool {
.any(|locked| locked.eq_ignore_ascii_case(protocol)) .any(|locked| locked.eq_ignore_ascii_case(protocol))
} }
impl ProtocolPolicy { /// The switch fields, by protocol name.
/// Whether a listener of this protocol and these ports is one the switch pub trait Switches {
/// closes. A listener bound to port 25 is inbound whatever its name, and /// The kill-all, which an unset per-protocol switch reads as.
/// any other SMTP listener counts as submission (LP-3). fn all(&self) -> LegacyProtocols;
pub fn closes(&self, protocol: &str, ports: &[u16]) -> bool { fn slot(&self, protocol: &str) -> Option<&Option<LegacyProtocols>>;
if !self.legacy_protocols.is_disabled() { fn slot_mut(&mut self, protocol: &str) -> Option<&mut Option<LegacyProtocols>>;
return false; fn set_all_field(&mut self, value: LegacyProtocols);
/// One protocol's switch. `submission` follows the kill-all: it is off
/// only when all three are. Anything else has no switch and is on.
fn switch(&self, protocol: &str) -> LegacyProtocols {
if protocol == SUBMISSION {
return if self.all_off() {
LegacyProtocols::Disabled
} else {
LegacyProtocols::Enabled
};
} }
match self.slot(protocol) {
Some(value) => value.unwrap_or(self.all()),
None => LegacyProtocols::Enabled,
}
}
/// Whether this protocol is off.
fn is_off(&self, protocol: &str) -> bool {
self.switch(protocol).is_disabled()
}
/// Whether all three protocols are off.
fn all_off(&self) -> bool {
SWITCHED.iter().all(|protocol| {
self.slot(protocol)
.and_then(|value| *value)
.unwrap_or(self.all())
.is_disabled()
})
}
/// Sets one protocol's switch; false if it has none.
fn set(&mut self, protocol: &str, value: LegacyProtocols) -> bool {
match self.slot_mut(protocol) {
Some(slot) => {
*slot = Some(value);
true
}
None => false,
}
}
/// The kill-all: all three at once.
fn set_all(&mut self, value: LegacyProtocols) {
for protocol in SWITCHED {
self.set(protocol, value);
}
self.set_all_field(value);
}
/// Writes out every switch and derives the kill-all from them, so what is
/// stored and shown never depends on how it was reached.
fn normalize(&mut self) {
let values: Vec<_> = SWITCHED.iter().map(|p| self.switch(p)).collect();
for (protocol, value) in SWITCHED.iter().zip(values) {
self.set(protocol, value);
}
let all = if self.all_off() {
LegacyProtocols::Disabled
} else {
LegacyProtocols::Enabled
};
self.set_all_field(all);
}
/// The protocols that are off.
fn off(&self) -> Vec<&'static str> {
SWITCHED
.iter()
.copied()
.filter(|p| self.is_off(p))
.collect()
}
}
macro_rules! switches {
($t:ty) => {
impl Switches for $t {
fn all(&self) -> LegacyProtocols {
self.legacy_protocols
}
fn slot(&self, protocol: &str) -> Option<&Option<LegacyProtocols>> {
match protocol {
"imap" => Some(&self.imap),
"pop3" => Some(&self.pop3),
"manageSieve" => Some(&self.manage_sieve),
_ => None,
}
}
fn slot_mut(&mut self, protocol: &str) -> Option<&mut Option<LegacyProtocols>> {
match protocol {
"imap" => Some(&mut self.imap),
"pop3" => Some(&mut self.pop3),
"manageSieve" => Some(&mut self.manage_sieve),
_ => None,
}
}
fn set_all_field(&mut self, value: LegacyProtocols) {
self.legacy_protocols = value;
}
}
};
}
pub(crate) use switches;
switches!(ProtocolPolicy);
impl ProtocolPolicy {
/// Whether a listener of this protocol and these ports is one the
/// switches close. A listener bound to port 25 is inbound whatever its
/// name, and any other SMTP listener counts as submission (LP-3), closed
/// only with all three off and `closeSubmission`.
pub fn closes(&self, protocol: &str, ports: &[u16]) -> bool {
// The lock is checked first and answers for every caller, so no // The lock is checked first and answers for every caller, so no
// request phrasing can reach past it (LP-21). // request phrasing can reach past it (LP-21).
if is_locked(protocol) { if is_locked(protocol) {
return false; return false;
} }
if LEGACY_PROTOCOLS.contains(&protocol) { if LEGACY_PROTOCOLS.contains(&protocol) {
return true; return self.is_off(protocol);
} }
protocol.eq_ignore_ascii_case("smtp") protocol.eq_ignore_ascii_case("smtp")
&& self.all_off()
&& self.close_submission && self.close_submission
&& !ports.contains(&INBOUND_SMTP_PORT) && !ports.contains(&INBOUND_SMTP_PORT)
} }
@@ -258,7 +407,10 @@ mod tests {
"an unset closeSubmission reads as the default, true" "an unset closeSubmission reads as the default, true"
); );
let json = serde_json::to_value(&policy).unwrap(); // As shown: normalized, every switch written out.
let mut shown = policy.clone();
shown.normalize();
let json = serde_json::to_value(&shown).unwrap();
for property in PROPERTIES { for property in PROPERTIES {
assert!(json.get(property).is_some(), "{property}"); assert!(json.get(property).is_some(), "{property}");
} }
@@ -410,6 +562,72 @@ mod tests {
); );
} }
/// A policy stored before the per-protocol switches reads as all three
/// at its one value.
#[test]
fn an_old_policy_reads_as_all_three() {
let old: ProtocolPolicy =
serde_json::from_str(r#"{"legacyProtocols": "disabled"}"#).unwrap();
for p in SWITCHED {
assert!(old.is_off(p), "{p}");
}
assert!(old.all_off() && old.is_off(SUBMISSION));
let old: ProtocolPolicy =
serde_json::from_str(r#"{"legacyProtocols": "enabled"}"#).unwrap();
assert!(old.off().is_empty() && !old.is_off(SUBMISSION));
}
/// One protocol off closes only its listeners, and leaves sending alone.
#[test]
fn one_protocol_off() {
let mut policy = ProtocolPolicy::default();
policy.set("pop3", LegacyProtocols::Disabled);
policy.normalize();
assert!(policy.closes("pop3", &[995]));
assert!(!policy.closes("imap", &[993]));
assert!(!policy.closes("manageSieve", &[4190]));
assert!(!policy.is_off(SUBMISSION), "sending goes on");
assert_eq!(policy.legacy_protocols, LegacyProtocols::Enabled);
assert_eq!(policy.off(), vec!["pop3"]);
let json = serde_json::to_value(&policy).unwrap();
assert_eq!(json["pop3"], "disabled");
assert_eq!(json["imap"], "enabled");
}
/// Turning the three off one at a time is the kill-all, and the kill-all
/// back on turns all three on.
#[test]
fn the_kill_all_is_all_three() {
let mut policy = ProtocolPolicy::default();
for p in SWITCHED {
policy.set(p, LegacyProtocols::Disabled);
}
policy.normalize();
assert!(policy.legacy_protocols.is_disabled());
assert!(policy.is_off(SUBMISSION));
policy.set_all(LegacyProtocols::Enabled);
policy.normalize();
assert!(policy.off().is_empty());
assert!(!policy.legacy_protocols.is_disabled());
// The kill-all then one back on: no longer all off.
policy.set_all(LegacyProtocols::Disabled);
policy.set("imap", LegacyProtocols::Enabled);
policy.normalize();
assert!(!policy.legacy_protocols.is_disabled());
assert_eq!(policy.off(), vec!["pop3", "manageSieve"]);
}
/// Protocols without a switch are never off.
#[test]
fn unswitched_protocols_are_on() {
let policy = disabled();
for p in ["smtp", "http", "lmtp", "jmap"] {
assert!(!policy.is_off(p), "{p}");
}
}
/// A saved listener with no id is refused, naming the property. /// A saved listener with no id is refused, naming the property.
#[test] #[test]
fn a_nameless_saved_listener_is_refused() { fn a_nameless_saved_listener_is_refused() {
@@ -9,12 +9,18 @@
//! the tenant id in the fork's subspace; a tenant with nothing stored has //! the tenant id in the fork's subspace; a tenant with nothing stored has
//! legacy protocols on. //! legacy protocols on.
//! //!
//! A tenant has the same three switches as the server (IMAP, POP3,
//! ManageSieve) and the same kill-all; a protocol off server-wide is off for
//! every tenant whatever the tenant's own switch says.
//!
//! A tenant's switch closes no port -- other tenants share them (LP-13). It //! A tenant's switch closes no port -- other tenants share them (LP-13). It
//! refuses sign-in on the tenant's domains, and keeps client configuration //! refuses sign-in on the tenant's domains, and keeps client configuration
//! for them from offering what's refused. That is all it is: one fact per //! for them from offering what's refused. That is all it is: one fact per
//! tenant, easy to turn back, touching no listener, role or permission. //! tenant, easy to turn back, touching no listener, role or permission.
use crate::security::protocol_policy::{LegacyProtocols, ProtocolPolicy}; use crate::security::protocol_policy::{
LegacyProtocols, ProtocolPolicy, SUBMISSION, SWITCHED, Switches, switches,
};
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize}; use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{ use store::{
Deserialize, SUBSPACE_INBUXA, Store, ValueKey, Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
@@ -26,24 +32,92 @@ use trc::AddContext;
#[derive(Debug, Clone, PartialEq, Default, SerdeSerialize, SerdeDeserialize)] #[derive(Debug, Clone, PartialEq, Default, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)] #[serde(rename_all = "camelCase", default)]
pub struct TenantProtocolPolicy { pub struct TenantProtocolPolicy {
/// The switch itself. /// The kill-all, as on the server's policy.
pub legacy_protocols: LegacyProtocols, pub legacy_protocols: LegacyProtocols,
/// IMAP's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub imap: Option<LegacyProtocols>,
/// POP3's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub pop3: Option<LegacyProtocols>,
/// ManageSieve's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub manage_sieve: Option<LegacyProtocols>,
/// When it last changed, in milliseconds since the epoch. /// When it last changed, in milliseconds since the epoch.
pub changed_at: Option<u64>, pub changed_at: Option<u64>,
/// The account that last changed it. /// The account that last changed it.
pub changed_by: Option<String>, pub changed_by: Option<String>,
} }
/// Why a tenant's switch can't be set this way, if it can't (LP-9). switches!(TenantProtocolPolicy);
/// Why a tenant's switches can't be set this way, if they can't (LP-9).
/// ///
/// A tenant can always turn legacy protocols off for itself. It can turn /// A tenant can always turn a protocol off for itself. It can turn one on
/// them back on only while the server has them on: server off means off for /// only while the server has it on: server off means off for everyone.
/// everyone. /// `turned_on` is what the request sets to `enabled`, by protocol name.
pub fn refusal(server: &ProtocolPolicy, requested: LegacyProtocols) -> Option<&'static str> { pub fn refusal(server: &ProtocolPolicy, turned_on: &[&str]) -> Option<String> {
(server.legacy_protocols.is_disabled() && !requested.is_disabled()).then_some( let blocked: Vec<&str> = turned_on
"Legacy mail protocols are off for the whole server (inbuxa:ProtocolPolicy), \ .iter()
so they can't be turned back on for one organization.", .copied()
) .filter(|protocol| server.is_off(protocol))
.collect();
(!blocked.is_empty()).then(|| {
format!(
"{} off for the whole server (inbuxa:ProtocolPolicy), so {} can't be turned \
back on for one organization.",
names(&blocked),
if blocked.len() == 1 { "it" } else { "they" }
)
})
}
/// Protocol names as people read them: "IMAP and POP3 are", "POP3 is".
fn names(protocols: &[&str]) -> String {
let named: Vec<&str> = protocols
.iter()
.map(|p| match *p {
"imap" => "IMAP",
"pop3" => "POP3",
"manageSieve" => "ManageSieve",
other => other,
})
.collect();
let list = match named.as_slice() {
[one] => one.to_string(),
[rest @ .., last] => format!("{} and {last}", rest.join(", ")),
[] => String::new(),
};
format!("{list} {}", if named.len() == 1 { "is" } else { "are" })
}
/// Whose switch turns a protocol off, if any.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OffBy {
Server,
Tenant,
}
/// Whether this protocol is off for an account or domain, and by whose
/// switch: the server's first (LP-6), then the tenant's (LP-10). Submission
/// is off when all three protocols are, counting both switches together.
pub fn off_by(
server: &ProtocolPolicy,
tenant: Option<&TenantProtocolPolicy>,
protocol: &str,
) -> Option<OffBy> {
if server.is_off(protocol) {
return Some(OffBy::Server);
}
let tenant = tenant?;
let off = if protocol == SUBMISSION {
SWITCHED
.iter()
.all(|p| server.is_off(p) || tenant.is_off(p))
} else {
tenant.is_off(protocol)
};
off.then_some(OffBy::Tenant)
} }
fn key(tenant_id: u32) -> ValueClass { fn key(tenant_id: u32) -> ValueClass {
@@ -115,6 +189,15 @@ mod tests {
} }
} }
fn tenant_off(protocols: &[&str]) -> TenantProtocolPolicy {
let mut policy = TenantProtocolPolicy::default();
for p in protocols {
policy.set(p, LegacyProtocols::Disabled);
}
policy.normalize();
policy
}
#[test] #[test]
fn a_tenant_starts_with_legacy_protocols_on() { fn a_tenant_starts_with_legacy_protocols_on() {
assert!( assert!(
@@ -127,20 +210,59 @@ mod tests {
#[test] #[test]
fn a_tenant_can_always_turn_them_off() { fn a_tenant_can_always_turn_them_off() {
for s in [LegacyProtocols::Enabled, LegacyProtocols::Disabled] { for s in [LegacyProtocols::Enabled, LegacyProtocols::Disabled] {
assert_eq!(refusal(&server(s), LegacyProtocols::Disabled), None); assert_eq!(refusal(&server(s), &[]), None);
} }
} }
#[test] #[test]
fn a_tenant_can_turn_them_on_only_while_the_server_has_them_on() { fn a_tenant_can_turn_them_on_only_while_the_server_has_them_on() {
// LP-9, acceptance test 9. // LP-9, acceptance test 9.
assert_eq!( assert_eq!(refusal(&server(LegacyProtocols::Enabled), SWITCHED), None);
refusal(&server(LegacyProtocols::Enabled), LegacyProtocols::Enabled), let why = refusal(&server(LegacyProtocols::Disabled), SWITCHED).expect("refused");
None
);
let why =
refusal(&server(LegacyProtocols::Disabled), LegacyProtocols::Enabled).expect("refused");
assert!(why.contains("inbuxa:ProtocolPolicy"), "{why}"); assert!(why.contains("inbuxa:ProtocolPolicy"), "{why}");
assert!(
why.starts_with("IMAP, POP3 and ManageSieve are off"),
"{why}"
);
}
#[test]
fn a_tenant_can_turn_on_what_the_server_allows() {
// The server has only POP3 off: IMAP may come back, POP3 may not.
let mut s = ProtocolPolicy::default();
s.set("pop3", LegacyProtocols::Disabled);
assert_eq!(refusal(&s, &["imap"]), None);
let why = refusal(&s, &["imap", "pop3"]).expect("refused");
assert!(why.starts_with("POP3 is off"), "{why}");
}
#[test]
fn whose_switch_turns_a_protocol_off() {
let mut s = ProtocolPolicy::default();
s.set("pop3", LegacyProtocols::Disabled);
let t = tenant_off(&["imap"]);
assert_eq!(off_by(&s, Some(&t), "pop3"), Some(OffBy::Server));
assert_eq!(off_by(&s, Some(&t), "imap"), Some(OffBy::Tenant));
assert_eq!(off_by(&s, Some(&t), "manageSieve"), None);
assert_eq!(off_by(&s, None, "imap"), None);
// Sending goes on while any protocol is still allowed.
assert_eq!(off_by(&s, Some(&t), SUBMISSION), None);
// Between them, all three off: submission follows (LP-6, LP-10).
let t = tenant_off(&["imap", "manageSieve"]);
assert_eq!(off_by(&s, Some(&t), SUBMISSION), Some(OffBy::Tenant));
assert_eq!(
off_by(&server(LegacyProtocols::Disabled), None, SUBMISSION),
Some(OffBy::Server)
);
}
#[test]
fn an_old_tenant_policy_reads_as_all_three() {
let Json(old) = Json::deserialize(br#"{"legacyProtocols":"disabled"}"#).unwrap();
for p in SWITCHED {
assert!(old.is_off(p), "{p}");
}
assert!(old.is_off(SUBMISSION));
} }
#[test] #[test]
@@ -158,6 +280,7 @@ mod tests {
legacy_protocols: LegacyProtocols::Disabled, legacy_protocols: LegacyProtocols::Disabled,
changed_at: Some(1), changed_at: Some(1),
changed_by: Some("b".into()), changed_by: Some("b".into()),
..Default::default()
}; };
let Json(back) = Json::deserialize(&serde_json::to_vec(&policy).unwrap()).unwrap(); let Json(back) = Json::deserialize(&serde_json::to_vec(&policy).unwrap()).unwrap();
assert_eq!(back, policy); assert_eq!(back, policy);
@@ -0,0 +1,211 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:HoldExport/get` and `/set` under `urn:inbuxa:jmap`: collecting
//! what a legal hold keeps as a ZIP (audit-hold-lock spec, LH-12). Creating
//! one starts it; it runs in the background, and `get` says when it's ready
//! and which blob to download. The set call's `reason` says why (AU-12).
use crate::{
object::{AnyId, JmapObject, JmapObjectId},
request::deserialize::DeserializeArguments,
};
use jmap_tools::{Element, Key, Property};
use std::{borrow::Cow, str::FromStr};
use types::id::Id;
#[derive(Debug, Clone, Default)]
pub struct HoldExport;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum HoldExportProperty {
Id,
HoldId,
AccountIds,
Reason,
Status,
CreatedAt,
CreatedBy,
FinishedAt,
BlobId,
Size,
Items,
Sha256,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum HoldExportValue {
Id(Id),
}
impl Property for HoldExportProperty {
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
match parent {
None => HoldExportProperty::parse(value),
Some(_) => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
HoldExportProperty::Id => "id",
HoldExportProperty::HoldId => "holdId",
HoldExportProperty::AccountIds => "accountIds",
HoldExportProperty::Reason => "reason",
HoldExportProperty::Status => "status",
HoldExportProperty::CreatedAt => "createdAt",
HoldExportProperty::CreatedBy => "createdBy",
HoldExportProperty::FinishedAt => "finishedAt",
HoldExportProperty::BlobId => "blobId",
HoldExportProperty::Size => "size",
HoldExportProperty::Items => "items",
HoldExportProperty::Sha256 => "sha256",
HoldExportProperty::Error => "error",
}
.into()
}
}
impl HoldExportProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => HoldExportProperty::Id,
b"holdId" => HoldExportProperty::HoldId,
b"accountIds" => HoldExportProperty::AccountIds,
b"reason" => HoldExportProperty::Reason,
b"status" => HoldExportProperty::Status,
b"createdAt" => HoldExportProperty::CreatedAt,
b"createdBy" => HoldExportProperty::CreatedBy,
b"finishedAt" => HoldExportProperty::FinishedAt,
b"blobId" => HoldExportProperty::BlobId,
b"size" => HoldExportProperty::Size,
b"items" => HoldExportProperty::Items,
b"sha256" => HoldExportProperty::Sha256,
b"error" => HoldExportProperty::Error,
)
}
}
impl FromStr for HoldExportProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
HoldExportProperty::parse(s).ok_or(())
}
}
impl Element for HoldExportValue {
type Property = HoldExportProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(HoldExportProperty::Id) => Id::from_str(value).ok().map(HoldExportValue::Id),
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
HoldExportValue::Id(id) => id.to_string().into(),
}
}
}
/// The set call's own arguments: why (AU-12).
#[derive(Debug, Clone, Default)]
pub struct HoldExportSetArguments {
pub reason: Option<String>,
}
impl<'de> DeserializeArguments<'de> for HoldExportSetArguments {
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
where
A: serde::de::MapAccess<'de>,
{
if key == "reason" {
self.reason = map.next_value()?;
} else {
let _ = map.next_value::<serde::de::IgnoredAny>()?;
}
Ok(())
}
}
impl JmapObject for HoldExport {
type Property = HoldExportProperty;
type Element = HoldExportValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = ();
type SetArguments<'de> = HoldExportSetArguments;
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = HoldExportProperty::Id;
}
impl From<Id> for HoldExportValue {
fn from(id: Id) -> Self {
HoldExportValue::Id(id)
}
}
impl JmapObjectId for HoldExportValue {
fn as_id(&self) -> Option<Id> {
match self {
HoldExportValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
HoldExportValue::Id(id) => Some(AnyId::Id(*id)),
}
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, new_id: AnyId) -> bool {
if let AnyId::Id(id) = new_id {
*self = HoldExportValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for HoldExportProperty {
fn as_id(&self) -> Option<Id> {
None
}
fn as_any_id(&self) -> Option<AnyId> {
None
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, _: AnyId) -> bool {
false
}
}
@@ -23,8 +23,13 @@ pub struct ProtocolPolicy;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ProtocolPolicyProperty { pub enum ProtocolPolicyProperty {
Id, Id,
/// The switch: `enabled` or `disabled`. /// The kill-all: `enabled` or `disabled`; reads `disabled` when all
/// three protocols are off, and sets all three.
LegacyProtocols, LegacyProtocols,
/// Each protocol's own switch: `enabled` or `disabled`.
Imap,
Pop3,
ManageSieve,
/// Whether submission closes with it. Forced false while SMTP is locked. /// Whether submission closes with it. Forced false while SMTP is locked.
CloseSubmission, CloseSubmission,
/// Server-set: the listeners taken away, for LP-5. /// Server-set: the listeners taken away, for LP-5.
@@ -56,6 +61,9 @@ impl Property for ProtocolPolicyProperty {
match self { match self {
ProtocolPolicyProperty::Id => "id", ProtocolPolicyProperty::Id => "id",
ProtocolPolicyProperty::LegacyProtocols => "legacyProtocols", ProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
ProtocolPolicyProperty::Imap => "imap",
ProtocolPolicyProperty::Pop3 => "pop3",
ProtocolPolicyProperty::ManageSieve => "manageSieve",
ProtocolPolicyProperty::CloseSubmission => "closeSubmission", ProtocolPolicyProperty::CloseSubmission => "closeSubmission",
ProtocolPolicyProperty::SavedListeners => "savedListeners", ProtocolPolicyProperty::SavedListeners => "savedListeners",
ProtocolPolicyProperty::ChangedAt => "changedAt", ProtocolPolicyProperty::ChangedAt => "changedAt",
@@ -73,6 +81,9 @@ impl ProtocolPolicyProperty {
hashify::tiny_map!(value.as_bytes(), hashify::tiny_map!(value.as_bytes(),
b"id" => ProtocolPolicyProperty::Id, b"id" => ProtocolPolicyProperty::Id,
b"legacyProtocols" => ProtocolPolicyProperty::LegacyProtocols, b"legacyProtocols" => ProtocolPolicyProperty::LegacyProtocols,
b"imap" => ProtocolPolicyProperty::Imap,
b"pop3" => ProtocolPolicyProperty::Pop3,
b"manageSieve" => ProtocolPolicyProperty::ManageSieve,
b"closeSubmission" => ProtocolPolicyProperty::CloseSubmission, b"closeSubmission" => ProtocolPolicyProperty::CloseSubmission,
b"savedListeners" => ProtocolPolicyProperty::SavedListeners, b"savedListeners" => ProtocolPolicyProperty::SavedListeners,
b"changedAt" => ProtocolPolicyProperty::ChangedAt, b"changedAt" => ProtocolPolicyProperty::ChangedAt,
@@ -24,8 +24,13 @@ pub enum TenantProtocolPolicyProperty {
Id, Id,
/// Server-set: the tenant this is the switch of. /// Server-set: the tenant this is the switch of.
TenantId, TenantId,
/// The switch: `enabled` or `disabled`. /// The kill-all: `enabled` or `disabled`; reads `disabled` when all
/// three protocols are off, and sets all three.
LegacyProtocols, LegacyProtocols,
/// Each protocol's own switch: `enabled` or `disabled`.
Imap,
Pop3,
ManageSieve,
ChangedAt, ChangedAt,
ChangedBy, ChangedBy,
/// Server-set: who signed in over a legacy protocol in the last 30 /// Server-set: who signed in over a legacy protocol in the last 30
@@ -48,6 +53,9 @@ impl Property for TenantProtocolPolicyProperty {
TenantProtocolPolicyProperty::Id => "id", TenantProtocolPolicyProperty::Id => "id",
TenantProtocolPolicyProperty::TenantId => "tenantId", TenantProtocolPolicyProperty::TenantId => "tenantId",
TenantProtocolPolicyProperty::LegacyProtocols => "legacyProtocols", TenantProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
TenantProtocolPolicyProperty::Imap => "imap",
TenantProtocolPolicyProperty::Pop3 => "pop3",
TenantProtocolPolicyProperty::ManageSieve => "manageSieve",
TenantProtocolPolicyProperty::ChangedAt => "changedAt", TenantProtocolPolicyProperty::ChangedAt => "changedAt",
TenantProtocolPolicyProperty::ChangedBy => "changedBy", TenantProtocolPolicyProperty::ChangedBy => "changedBy",
TenantProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse", TenantProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse",
@@ -62,6 +70,9 @@ impl TenantProtocolPolicyProperty {
b"id" => TenantProtocolPolicyProperty::Id, b"id" => TenantProtocolPolicyProperty::Id,
b"tenantId" => TenantProtocolPolicyProperty::TenantId, b"tenantId" => TenantProtocolPolicyProperty::TenantId,
b"legacyProtocols" => TenantProtocolPolicyProperty::LegacyProtocols, b"legacyProtocols" => TenantProtocolPolicyProperty::LegacyProtocols,
b"imap" => TenantProtocolPolicyProperty::Imap,
b"pop3" => TenantProtocolPolicyProperty::Pop3,
b"manageSieve" => TenantProtocolPolicyProperty::ManageSieve,
b"changedAt" => TenantProtocolPolicyProperty::ChangedAt, b"changedAt" => TenantProtocolPolicyProperty::ChangedAt,
b"changedBy" => TenantProtocolPolicyProperty::ChangedBy, b"changedBy" => TenantProtocolPolicyProperty::ChangedBy,
b"recentLegacyUse" => TenantProtocolPolicyProperty::RecentLegacyUse, b"recentLegacyUse" => TenantProtocolPolicyProperty::RecentLegacyUse,
+1
View File
@@ -25,6 +25,7 @@ pub mod inbuxa_account_lock; // inbuxa: account lock with delegation
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
pub mod inbuxa_audit; // inbuxa: the audit log pub mod inbuxa_audit; // inbuxa: the audit log
pub mod inbuxa_legal_hold; // inbuxa: legal hold pub mod inbuxa_legal_hold; // inbuxa: legal hold
pub mod inbuxa_hold_export; // inbuxa: legal hold exports
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant
+3
View File
@@ -73,6 +73,9 @@ impl Response<'_> {
GetResponseMethod::LegalHold(response) => { GetResponseMethod::LegalHold(response) => {
response.eval_jptr(path, &mut results) response.eval_jptr(path, &mut results)
} }
GetResponseMethod::HoldExport(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::ProtocolPolicy(response) => { GetResponseMethod::ProtocolPolicy(response) => {
response.eval_jptr(path, &mut results) response.eval_jptr(path, &mut results)
} }
@@ -50,6 +50,7 @@ impl Response<'_> {
GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?, GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?,
GetRequestMethod::AccountLock(request) => request.resolve_references(self)?, GetRequestMethod::AccountLock(request) => request.resolve_references(self)?,
GetRequestMethod::LegalHold(request) => request.resolve_references(self)?, GetRequestMethod::LegalHold(request) => request.resolve_references(self)?,
GetRequestMethod::HoldExport(request) => request.resolve_references(self)?,
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?, GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
GetRequestMethod::TenantProtocolPolicy(request) => { GetRequestMethod::TenantProtocolPolicy(request) => {
request.resolve_references(self)? request.resolve_references(self)?
@@ -115,6 +116,9 @@ impl Response<'_> {
SetRequestMethod::LegalHold(request) => { SetRequestMethod::LegalHold(request) => {
request.resolve_references(self, 1, false)? request.resolve_references(self, 1, false)?
} }
SetRequestMethod::HoldExport(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::ProtocolPolicy(request) => { SetRequestMethod::ProtocolPolicy(request) => {
request.resolve_references(self, 1, false)? request.resolve_references(self, 1, false)?
} }
@@ -169,6 +169,11 @@ pub struct InbuxaAccountCapabilities {
/// (legacy-protocols spec, Interfaces; LP-19). /// (legacy-protocols spec, Interfaces; LP-19).
#[serde(rename(serialize = "legacyProtocols"))] #[serde(rename(serialize = "legacyProtocols"))]
pub legacy_protocols: &'static str, pub legacy_protocols: &'static str,
/// The legacy protocols still allowed for the principal, each the
/// stricter of the two switches: `imap`, `pop3`, `manageSieve`,
/// `submission` (legacy-protocols spec, one switch per protocol).
#[serde(rename(serialize = "legacyAllowed"))]
pub legacy_allowed: Vec<&'static str>,
/// Whether the principal may use "Explain this" now: it holds /// Whether the principal may use "Explain this" now: it holds
/// `sysAiExplain`, is server-level, and a model resolves (ai-explain /// `sysAiExplain`, is server-level, and a model resolves (ai-explain
/// spec, EX-1 to EX-4). /// spec, EX-1 to EX-4).
+8 -1
View File
@@ -60,6 +60,7 @@ pub enum MethodObject {
AccountLock, AccountLock,
// inbuxa: legal hold // inbuxa: legal hold
LegalHold, LegalHold,
HoldExport,
ProtocolPolicy, ProtocolPolicy,
TenantProtocolPolicy, TenantProtocolPolicy,
} }
@@ -94,7 +95,8 @@ impl MethodObject {
| MethodObject::AuditExport | MethodObject::AuditExport
| MethodObject::AuditVerification | MethodObject::AuditVerification
| MethodObject::AccountLock | MethodObject::AccountLock
| MethodObject::LegalHold => Capability::Inbuxa, | MethodObject::LegalHold
| MethodObject::HoldExport => Capability::Inbuxa,
MethodObject::ProtocolPolicy => Capability::Inbuxa, MethodObject::ProtocolPolicy => Capability::Inbuxa,
MethodObject::TenantProtocolPolicy => Capability::Inbuxa, MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
} }
@@ -284,6 +286,8 @@ impl MethodName {
(MethodFunction::Set, MethodObject::AccountLock) => "inbuxa:AccountLock/set", (MethodFunction::Set, MethodObject::AccountLock) => "inbuxa:AccountLock/set",
(MethodFunction::Get, MethodObject::LegalHold) => "inbuxa:LegalHold/get", (MethodFunction::Get, MethodObject::LegalHold) => "inbuxa:LegalHold/get",
(MethodFunction::Set, MethodObject::LegalHold) => "inbuxa:LegalHold/set", (MethodFunction::Set, MethodObject::LegalHold) => "inbuxa:LegalHold/set",
(MethodFunction::Get, MethodObject::HoldExport) => "inbuxa:HoldExport/get",
(MethodFunction::Set, MethodObject::HoldExport) => "inbuxa:HoldExport/set",
(MethodFunction::Set, MethodObject::AuditVerification) => { (MethodFunction::Set, MethodObject::AuditVerification) => {
"inbuxa:AuditVerification/set" "inbuxa:AuditVerification/set"
} }
@@ -430,6 +434,8 @@ impl MethodName {
"inbuxa:AccountLock/set" => (MethodObject::AccountLock, MethodFunction::Set), "inbuxa:AccountLock/set" => (MethodObject::AccountLock, MethodFunction::Set),
"inbuxa:LegalHold/get" => (MethodObject::LegalHold, MethodFunction::Get), "inbuxa:LegalHold/get" => (MethodObject::LegalHold, MethodFunction::Get),
"inbuxa:LegalHold/set" => (MethodObject::LegalHold, MethodFunction::Set), "inbuxa:LegalHold/set" => (MethodObject::LegalHold, MethodFunction::Set),
"inbuxa:HoldExport/get" => (MethodObject::HoldExport, MethodFunction::Get),
"inbuxa:HoldExport/set" => (MethodObject::HoldExport, MethodFunction::Set),
"inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set), "inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set),
"inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get), "inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get),
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set), "inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
@@ -495,6 +501,7 @@ impl Display for MethodObject {
MethodObject::AuditVerification => "inbuxa:AuditVerification", MethodObject::AuditVerification => "inbuxa:AuditVerification",
MethodObject::AccountLock => "inbuxa:AccountLock", MethodObject::AccountLock => "inbuxa:AccountLock",
MethodObject::LegalHold => "inbuxa:LegalHold", MethodObject::LegalHold => "inbuxa:LegalHold",
MethodObject::HoldExport => "inbuxa:HoldExport",
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy", MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy", MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
MethodObject::Registry(obj) => { MethodObject::Registry(obj) => {
+2
View File
@@ -120,6 +120,7 @@ pub enum GetRequestMethod {
AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>), AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>),
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>), AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>), LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>),
HoldExport(Box<GetRequest<crate::object::inbuxa_hold_export::HoldExport>>),
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>), ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
TenantProtocolPolicy( TenantProtocolPolicy(
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>, Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
@@ -153,6 +154,7 @@ pub enum SetRequestMethod<'x> {
AuditVerification(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditVerification>>), AuditVerification(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditVerification>>),
AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>), AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>),
LegalHold(Box<SetRequest<'x, crate::object::inbuxa_legal_hold::LegalHold>>), LegalHold(Box<SetRequest<'x, crate::object::inbuxa_legal_hold::LegalHold>>),
HoldExport(Box<SetRequest<'x, crate::object::inbuxa_hold_export::HoldExport>>),
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>), ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
TenantProtocolPolicy( TenantProtocolPolicy(
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>, Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
+15
View File
@@ -566,6 +566,21 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self)); return Err(de::Error::invalid_length(1, &self));
} }
}, },
// inbuxa: legal hold exports
(MethodFunction::Get, MethodObject::HoldExport) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::HoldExport(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::HoldExport) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::HoldExport(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
// inbuxa: legal hold // inbuxa: legal hold
(MethodFunction::Get, MethodObject::LegalHold) => match seq.next_element() { (MethodFunction::Get, MethodObject::LegalHold) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LegalHold(value)), Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LegalHold(value)),
+15
View File
@@ -107,6 +107,7 @@ pub enum GetResponseMethod {
AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>), AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>),
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>), AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>), LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>),
HoldExport(GetResponse<crate::object::inbuxa_hold_export::HoldExport>),
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>), ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
TenantProtocolPolicy( TenantProtocolPolicy(
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>, GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
@@ -140,6 +141,7 @@ pub enum SetResponseMethod {
AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>), AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>),
AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>), AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>),
LegalHold(Box<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>>), LegalHold(Box<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>>),
HoldExport(Box<SetResponse<crate::object::inbuxa_hold_export::HoldExport>>),
Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>), Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>),
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>), ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
TenantProtocolPolicy( TenantProtocolPolicy(
@@ -780,3 +782,16 @@ impl<'x> From<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>> for Resp
ResponseMethod::Set(SetResponseMethod::LegalHold(Box::new(value))) ResponseMethod::Set(SetResponseMethod::LegalHold(Box::new(value)))
} }
} }
// inbuxa: legal hold exports
impl<'x> From<GetResponse<crate::object::inbuxa_hold_export::HoldExport>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_hold_export::HoldExport>) -> Self {
ResponseMethod::Get(GetResponseMethod::HoldExport(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_hold_export::HoldExport>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_hold_export::HoldExport>) -> Self {
ResponseMethod::Set(SetResponseMethod::HoldExport(Box::new(value)))
}
}
+1
View File
@@ -39,6 +39,7 @@ base64 = "0.23"
p256 = { version = "0.13", features = ["ecdh"] } p256 = { version = "0.13", features = ["ecdh"] }
sha1 = "0.11" sha1 = "0.11"
sha2 = "0.11" sha2 = "0.11"
zip = "8.6" # inbuxa: legal hold exports (LH-12)
reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"]} reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"]}
tokio-tungstenite = "0.30" tokio-tungstenite = "0.30"
tungstenite = "0.30" tungstenite = "0.30"
+10
View File
@@ -97,6 +97,7 @@ impl JmapAuthorization for AccessToken {
// inbuxa: account lock (AL-12) // inbuxa: account lock (AL-12)
GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet, GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet,
GetRequestMethod::LegalHold(_) => Permission::SysLegalHoldGet, GetRequestMethod::LegalHold(_) => Permission::SysLegalHoldGet,
GetRequestMethod::HoldExport(_) => Permission::SysLegalHoldExport,
// inbuxa: legacy protocols off. It takes listeners away and // inbuxa: legacy protocols off. It takes listeners away and
// puts them back, so it takes the listener's permissions // puts them back, so it takes the listener's permissions
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet, GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
@@ -232,6 +233,14 @@ impl JmapAuthorization for AccessToken {
Permission::SysLegalHoldUpdate, Permission::SysLegalHoldUpdate,
Permission::SysLegalHoldUpdate, Permission::SysLegalHoldUpdate,
), ),
// inbuxa: LH-12, exporting held data
SetRequestMethod::HoldExport(s) => validate_set(
s,
self,
Permission::SysLegalHoldExport,
Permission::SysLegalHoldExport,
Permission::SysLegalHoldExport,
),
SetRequestMethod::AuditVerification(s) => validate_set( SetRequestMethod::AuditVerification(s) => validate_set(
s, s,
self, self,
@@ -380,6 +389,7 @@ impl JmapAuthorization for AccessToken {
| MethodObject::AuditVerification | MethodObject::AuditVerification
| MethodObject::AccountLock | MethodObject::AccountLock
| MethodObject::LegalHold | MethodObject::LegalHold
| MethodObject::HoldExport
| MethodObject::ProtocolPolicy | MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges, | MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
// inbuxa: x:MaskedEmail/changes reads what /get reads // inbuxa: x:MaskedEmail/changes reads what /get reads
+36
View File
@@ -276,6 +276,9 @@ impl RequestHandler for Server {
SetResponseMethod::LegalHold(set_response) => { SetResponseMethod::LegalHold(set_response) => {
set_response.update_created_ids(&mut response); set_response.update_created_ids(&mut response);
} }
SetResponseMethod::HoldExport(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::Explanation(set_response) => { SetResponseMethod::Explanation(set_response) => {
set_response.update_created_ids(&mut response); set_response.update_created_ids(&mut response);
} }
@@ -450,6 +453,11 @@ impl RequestHandler for Server {
.into() .into()
} }
// inbuxa: legal hold (LH-1) // inbuxa: legal hold (LH-1)
// inbuxa: legal hold exports (LH-12)
GetRequestMethod::HoldExport(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::hold_export_api::get(self, *req).await?.into()
}
GetRequestMethod::LegalHold(mut req) => { GetRequestMethod::LegalHold(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::legal_hold::get(self, *req).await?.into() crate::inbuxa::legal_hold::get(self, *req).await?.into()
@@ -807,6 +815,34 @@ impl RequestHandler for Server {
} }
// inbuxa: legal hold (LH-1), each change recorded with its // inbuxa: legal hold (LH-1), each change recorded with its
// reason (AU-12) // reason (AU-12)
// inbuxa: legal hold exports, recorded with their reason
// (AU-1.9, AU-12)
SetRequestMethod::HoldExport(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
let reason = req.arguments.reason.clone().or_else(|| {
req.create.as_ref().and_then(|create| {
create.values().find_map(|value| {
serde_json::to_value(value)
.ok()?
.get("reason")?
.as_str()
.map(str::to_string)
})
})
});
crate::inbuxa::audit::recorded(
self,
access_token,
session,
&method_name.obj.to_string(),
None,
reason,
*req,
|req| Box::pin(crate::inbuxa::hold_export_api::set(self, access_token, req)),
)
.await?
.into()
}
SetRequestMethod::LegalHold(mut req) => { SetRequestMethod::LegalHold(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
let reason = req.arguments.reason.clone().or_else(|| { let reason = req.arguments.reason.clone().or_else(|| {
+7 -2
View File
@@ -66,12 +66,16 @@ impl SessionHandler for Server {
Capability::Inbuxa, Capability::Inbuxa,
Capabilities::Empty(EmptyCapabilities::default()), Capabilities::Empty(EmptyCapabilities::default()),
); );
// inbuxa: legacy-protocols, Interfaces: whichever switch is stricter // inbuxa: legacy-protocols, Interfaces: whichever switch is stricter,
let legacy_protocols = if self.legacy_protocols_off_for_account(access_token).await? { // per protocol. `legacyProtocols` stays for older webmail builds:
// `disabled` only when every protocol is off.
let legacy_off = self.legacy_off_for_account(access_token).await?;
let legacy_protocols = if legacy_off.all() {
"disabled" "disabled"
} else { } else {
"enabled" "enabled"
}; };
let legacy_allowed = legacy_off.allowed();
// inbuxa: ai-explain, EX-1 to EX-4: whether Explain can be offered // inbuxa: ai-explain, EX-1 to EX-4: whether Explain can be offered
let ai_explain = access_token.has_permission(Permission::SysAiExplain) let ai_explain = access_token.has_permission(Permission::SysAiExplain)
&& access_token.tenant_id().is_none() && access_token.tenant_id().is_none()
@@ -81,6 +85,7 @@ impl SessionHandler for Server {
Capabilities::Inbuxa(InbuxaAccountCapabilities { Capabilities::Inbuxa(InbuxaAccountCapabilities {
logo, logo,
legacy_protocols, legacy_protocols,
legacy_allowed,
ai_explain, ai_explain,
}), }),
); );
+1
View File
@@ -425,6 +425,7 @@ impl IntermediateChangesResponse {
| MethodObject::AuditVerification | MethodObject::AuditVerification
| MethodObject::AccountLock | MethodObject::AccountLock
| MethodObject::LegalHold | MethodObject::LegalHold
| MethodObject::HoldExport
| MethodObject::ProtocolPolicy | MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy | MethodObject::TenantProtocolPolicy
| MethodObject::Registry(_) => unreachable!(), | MethodObject::Registry(_) => unreachable!(),
+20 -6
View File
@@ -376,7 +376,11 @@ async fn full_name(server: &Server, object: &str, value: &Value, name: Option<St
} }
async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> Option<Value> { async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> Option<Value> {
use inbuxa_features::{ai::limits, audit::log, security}; use inbuxa_features::{
ai::limits,
audit::log,
security::{self, protocol_policy::Switches},
};
let data = server.store(); let data = server.store();
match object { match object {
"inbuxa:AuditSettings" => log::settings(data) "inbuxa:AuditSettings" => log::settings(data)
@@ -387,10 +391,17 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> O
.await .await
.ok() .ok()
.and_then(|limits| serde_json::to_value(limits).ok()), .and_then(|limits| serde_json::to_value(limits).ok()),
"inbuxa:ProtocolPolicy" => security::protocol_policy::get(data) // Normalized, so every switch reads before and after, even from a
.await // policy stored before the per-protocol switches
.ok() "inbuxa:ProtocolPolicy" => {
.and_then(|policy| serde_json::to_value(policy).ok()), security::protocol_policy::get(data)
.await
.ok()
.and_then(|mut policy| {
policy.normalize();
serde_json::to_value(policy).ok()
})
}
// LH-1: a hold as the API shows it, so a change reads before/after // LH-1: a hold as the API shows it, so a change reads before/after
"inbuxa:LegalHold" => match id { "inbuxa:LegalHold" => match id {
MaybeInvalid::Value(id) => { MaybeInvalid::Value(id) => {
@@ -424,7 +435,10 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> O
security::tenant_protocol_policy::get(data, id.document_id()) security::tenant_protocol_policy::get(data, id.document_id())
.await .await
.ok() .ok()
.and_then(|policy| serde_json::to_value(policy).ok()) .and_then(|mut policy| {
policy.normalize();
serde_json::to_value(policy).ok()
})
} }
MaybeInvalid::Invalid(_) => None, MaybeInvalid::Invalid(_) => None,
}, },
+535
View File
@@ -0,0 +1,535 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Collecting what a legal hold keeps, as a ZIP (audit-hold-lock spec,
//! LH-12). For each account the hold covers (or those asked for): its mail,
//! 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
//! 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 email::{
cache::MessageCacheFetch,
message::metadata::{MESSAGE_RECEIVED_MASK, MessageMetadata},
};
use groupware::{cache::GroupwareCache, calendar::CalendarEvent, contact::ContactCard, file::FileNode};
use inbuxa_features::{
hold::{Hold, Keeping, is_held_until},
undelete::records,
};
use registry::schema::{prelude::ObjectType, structs::ArchivedItem};
use sha2::{Digest, Sha256};
use std::io::{Cursor, Write};
use store::{
ValueKey,
registry::RegistryQuery,
write::{AlignedBytes, Archive},
};
use trc::AddContext;
use types::{
collection::{Collection, SyncCollection},
field::EmailField,
id::Id,
};
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
/// The largest ZIP built in memory. A bigger collection is refused with a
/// clear error rather than taking the node down; export fewer accounts.
pub const MAX_EXPORT: u64 = 2 * 1024 * 1024 * 1024;
/// One line of `manifest.csv`.
struct Entry {
path: String,
account: String,
kind: &'static str,
folder: String,
date: Option<i64>,
archived: bool,
size: usize,
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 {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn csv(field: &str) -> String {
if field.contains([',', '"', '\n', '\r']) {
format!("\"{}\"", field.replace('"', "\"\""))
} else {
field.to_string()
}
}
/// A path segment that's safe in a ZIP: no separators, no leading dots.
fn segment(name: &str) -> String {
let cleaned: String = name
.chars()
.map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c })
.collect();
let trimmed = cleaned.trim_start_matches('.').trim();
if trimmed.is_empty() { "_".into() } else { trimmed.chars().take(120).collect() }
}
fn date_text(at: Option<i64>) -> String {
at.map(|at| jmap_proto::types::date::UTCDate::from_timestamp(at).to_string())
.unwrap_or_default()
}
struct Builder {
zip: ZipWriter<Cursor<Vec<u8>>>,
entries: Vec<Entry>,
missing: Vec<Missing>,
written: u64,
}
impl Builder {
fn new() -> Self {
Builder {
zip: ZipWriter::new(Cursor::new(Vec::new())),
entries: Vec::new(),
missing: Vec::new(),
written: 0,
}
}
#[allow(clippy::too_many_arguments)]
fn add(
&mut self,
path: String,
bytes: &[u8],
account: &str,
kind: &'static str,
folder: &str,
date: Option<i64>,
archived: bool,
) -> trc::Result<()> {
self.written += bytes.len() as u64;
if self.written > MAX_EXPORT {
return Err(trc::StoreEvent::UnexpectedError
.into_err()
.details("The collection is larger than one export can hold (2 GB). Export fewer accounts at a time."));
}
// Unique within the ZIP, however names collide
let mut name = path.clone();
let mut n = 1;
while self.entries.iter().any(|e| e.path == name) {
n += 1;
name = match path.rsplit_once('.') {
Some((stem, ext)) if !stem.ends_with('/') => format!("{stem} ({n}).{ext}"),
_ => format!("{path} ({n})"),
};
}
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
self.zip
.start_file(name.as_str(), options)
.and_then(|_| self.zip.write_all(bytes).map_err(Into::into))
.map_err(|err| {
trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to write the export")
.reason(err)
})?;
self.entries.push(Entry {
path: name,
account: account.to_string(),
kind,
folder: folder.to_string(),
date,
archived,
size: bytes.len(),
sha256: hex(&Sha256::digest(bytes)),
});
Ok(())
}
/// Records an item the hold covers that couldn't be read.
#[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)> {
let mut manifest = String::from("path,account,kind,folder,date,archived,size,sha256\n");
for e in &self.entries {
manifest.push_str(&format!(
"{},{},{},{},{},{},{},{}\n",
csv(&e.path),
csv(&e.account),
e.kind,
csv(&e.folder),
date_text(e.date),
e.archived,
e.size,
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 exceptions_hash = hex(&Sha256::digest(exceptions.as_bytes()));
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
let fail = |err: zip::result::ZipError| {
trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to write the export")
.reason(err)
};
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.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
.write_all(format!("{manifest_hash} manifest.csv\n{exceptions_hash} exceptions.csv\n").as_bytes())
.map_err(|e| fail(e.into()))?;
let items = self.entries.len();
let bytes = self.zip.finish().map_err(fail)?.into_inner();
Ok((bytes, items))
}
}
/// The accounts to collect: those asked for that the hold covers, or every
/// account it covers, deleted ones it keeps included.
async fn accounts(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<Vec<u32>> {
let mut covered = Vec::new();
for id in server
.registry()
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Account))
.await
.caused_by(trc::location!())?
{
let account_id = id.document_id();
if let Some(member) = server.member_of(account_id).await
&& hold.scope.covers(&member)
{
covered.push(account_id);
}
}
for (account_id, kept) in inbuxa_features::undelete::data::kept_accounts(server.store()).await? {
if hold.scope.covers(&kept_member(account_id, &kept)) {
covered.push(account_id);
}
}
covered.sort_unstable();
covered.dedup();
if !asked.is_empty() {
covered.retain(|id| asked.contains(id));
}
Ok(covered)
}
async fn blob(server: &Server, hash: &[u8]) -> trc::Result<Option<Vec<u8>>> {
server.blob_store().get_blob(hash, 0..usize::MAX).await
}
/// Collects `accounts` under `hold` into a ZIP. Returns its bytes and item
/// count.
pub async fn build(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<(Vec<u8>, usize)> {
let keeping = Keeping::new(None, std::slice::from_ref(hold));
let data = server.store();
let mut out = Builder::new();
for account_id in accounts(server, hold, asked).await? {
let address = server.audit_account_name(account_id).await;
let base = format!("{}/", segment(&address));
let live = server.account(account_id).await.is_ok();
if live {
// Mail, by the folder it's in
let cache = server
.get_cached_messages(account_id)
.await
.caused_by(trc::location!())?;
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
.get_value::<Archive<AlignedBytes>>(ValueKey::property(
account_id,
Collection::Email,
message.document_id,
EmailField::Metadata,
))
.await?
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;
};
let metadata = metadata_
.unarchive::<MessageMetadata>()
.caused_by(trc::location!())?;
let received = metadata.rcvd_attach.to_native() & MESSAGE_RECEIVED_MASK;
if !keeping.covers(Some(received)) {
continue;
}
let hash = types::blob_hash::BlobHash::from(&metadata.blob_hash);
match blob(server, hash.as_slice()).await? {
Some(bytes) => {
out.add(mail_path(&folder), &bytes, &address, "email", &folder, Some(received as i64), false)?
}
None => out.missing(
mail_path(&folder),
&address,
"email",
&folder,
Some(received as i64),
false,
NO_BLOB,
),
}
}
// Calendars, contacts and files, by their DAV paths
for (sync, kind) in [
(SyncCollection::Calendar, "event"),
(SyncCollection::AddressBook, "contact"),
(SyncCollection::FileNode, "file"),
] {
let resources = server
.fetch_dav_resources(account_id, account_id, sync)
.await
.caused_by(trc::location!())?;
for path in resources.paths.iter() {
let Some(resource) = resources.resources.get(path.resource_idx) else {
continue;
};
let folder = path.path.rsplit_once('/').map(|(f, _)| f).unwrap_or_default();
let zip_path = |ext: Option<&str>| {
let mut p = format!(
"{base}{}/{}",
match kind {
"event" => "calendar",
"contact" => "contacts",
_ => "files",
},
path.path.split('/').map(segment).collect::<Vec<_>>().join("/")
);
if let Some(ext) = ext
&& !p.ends_with(ext)
{
p.push_str(ext);
}
p
};
use common::DavResourceMetadata as M;
match &resource.data {
M::CalendarEvent { start, .. } => {
if !keeping.covers_event(Some((*start).max(0) as u64)) {
continue;
}
let Some(event_) = data
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
account_id,
Collection::CalendarEvent,
resource.document_id,
))
.await?
else {
out.missing(zip_path(Some(".ics")), &address, kind, folder, Some(*start), false, NO_RECORD);
continue;
};
let event = event_.unarchive::<CalendarEvent>().caused_by(trc::location!())?;
let text = event.data.event.to_string();
out.add(zip_path(Some(".ics")), text.as_bytes(), &address, kind, folder, Some(*start), false)?;
}
M::ContactCard { .. } => {
let Some(card_) = data
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
account_id,
Collection::ContactCard,
resource.document_id,
))
.await?
else {
out.missing(zip_path(Some(".vcf")), &address, kind, folder, None, false, NO_RECORD);
continue;
};
let card = card_.unarchive::<ContactCard>().caused_by(trc::location!())?;
let mut text = String::with_capacity(256);
let _ = card.card.write_to(&mut text, server.core.groupware.vcard_version);
out.add(zip_path(Some(".vcf")), text.as_bytes(), &address, kind, folder, None, false)?;
}
M::File { size: Some(_), .. } => {
let Some(file_) = data
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
account_id,
Collection::FileNode,
resource.document_id,
))
.await?
else {
out.missing(zip_path(None), &address, kind, folder, None, false, NO_RECORD);
continue;
};
let file = file_.unarchive::<FileNode>().caused_by(trc::location!())?;
let Some(props) = file.file.as_ref() else {
out.missing(zip_path(None), &address, kind, folder, None, false, NO_RECORD);
continue;
};
let hash = types::blob_hash::BlobHash::from(&props.blob_hash);
match blob(server, hash.as_slice()).await? {
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),
}
}
_ => {}
}
}
}
}
// What the hold keeps of what was deleted
for (id, item) in records::of_account(data, server.registry(), account_id).await? {
if !is_held_until(item.archived_until().timestamp().max(0) as u64) {
continue;
}
let (kind, ext, date) = match &item {
ArchivedItem::Email(e) => ("email", ".eml", Some(e.received_at.timestamp())),
ArchivedItem::CalendarEvent(e) => ("event", ".ics", e.start_time.map(|t| t.timestamp())),
ArchivedItem::ContactCard(_) => ("contact", ".vcf", None),
ArchivedItem::FileNode(_) => ("file", "", None),
ArchivedItem::SieveScript(_) => ("sieve", ".sieve", None),
};
// Kept by this hold, not only by another one over the same account
let in_range = match kind {
"event" => keeping.covers_event(date.map(|d| d.max(0) as u64)),
_ => keeping.covers(date.map(|d| d.max(0) as u64)),
};
if !in_range {
continue;
}
let name = match &item {
ArchivedItem::FileNode(f) => segment(&f.name),
ArchivedItem::SieveScript(s) => format!("{}{ext}", segment(&s.name)),
_ => format!("{id}{ext}"),
};
let path = format!("{base}archived/{kind}/{name}");
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),
}
}
}
out.finish()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_are_safe_in_a_zip() {
assert_eq!(segment("../etc/passwd"), "_etc_passwd");
assert_eq!(segment(" "), "_");
assert_eq!(segment("Q3 report.pdf"), "Q3 report.pdf");
assert_eq!(csv("a,b"), "\"a,b\"");
assert_eq!(csv("say \"hi\""), "\"say \"\"hi\"\"\"");
}
#[test]
fn a_zip_carries_its_manifest_and_its_hash() {
let mut b = Builder::new();
b.add("[email protected]/mail/INBOX/b.eml".into(), b"Subject: x\r\n\r\ny", "[email protected]", "email", "INBOX", Some(0), false)
.unwrap();
b.add("[email protected]/mail/INBOX/b.eml".into(), b"other", "[email protected]", "email", "INBOX", None, true)
.unwrap();
let (bytes, items) = b.finish().unwrap();
assert_eq!(items, 2);
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
let mut manifest = String::new();
std::io::Read::read_to_string(&mut zip.by_name("manifest.csv").unwrap(), &mut manifest).unwrap();
assert!(manifest.contains("[email protected]/mail/INBOX/b (2).eml"), "{manifest}");
let mut hash = String::new();
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()))));
// 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())))));
}
}
+294
View File
@@ -0,0 +1,294 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:HoldExport` (audit-hold-lock spec, LH-12): starting a collection
//! of what a hold keeps, and seeing how it went. The ZIP is the creator's
//! blob, to download once it's ready. Creating one is audited, with its
//! reason (AU-1.9, AU-12).
use common::{Server, auth::AccessToken};
use inbuxa_features::hold::{self, Export, ExportStatus};
use jmap_proto::{
error::set::SetError,
method::{
get::{GetRequest, GetResponse},
set::{SetRequest, SetResponse},
},
object::inbuxa_hold_export::{
HoldExport, HoldExportProperty as P, HoldExportSetArguments, HoldExportValue,
},
types::date::UTCDate,
};
use jmap_tools::{Key, Map, Value};
use sha2::{Digest, Sha256};
use std::str::FromStr;
use store::write::now;
use types::id::Id;
type LValue = Value<'static, P, HoldExportValue>;
const ALL: &[P] = &[
P::Id,
P::HoldId,
P::AccountIds,
P::Reason,
P::Status,
P::CreatedAt,
P::CreatedBy,
P::FinishedAt,
P::BlobId,
P::Size,
P::Items,
P::Sha256,
P::Error,
];
fn date(seconds: u64) -> LValue {
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
}
fn opt_text(value: &Option<String>) -> LValue {
value.as_ref().map_or(Value::Null, |v| Value::Str(v.clone().into()))
}
fn to_value(export: &Export, properties: &[P]) -> LValue {
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
P::Id => Value::Element(HoldExportValue::Id(Id::from(export.id))),
P::HoldId => Value::Str(Id::from(export.hold_id).to_string().into()),
P::AccountIds => Value::Array(
export
.accounts
.iter()
.map(|id| Value::Str(Id::from(*id).to_string().into()))
.collect(),
),
P::Reason => Value::Str(export.reason.clone().into()),
P::Status => Value::Str(
match export.status {
ExportStatus::Running => "running",
ExportStatus::Ready => "ready",
ExportStatus::Failed => "failed",
}
.into(),
),
P::CreatedAt => date(export.created_at),
P::CreatedBy => Value::Str(export.created_by.clone().into()),
P::FinishedAt => export.finished_at.map_or(Value::Null, date),
P::BlobId => opt_text(&export.blob_id),
P::Size => Value::Number(export.size.into()),
P::Items => Value::Number(export.items.into()),
P::Sha256 => opt_text(&export.sha256),
P::Error => opt_text(&export.error),
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// `inbuxa:HoldExport/get`: every export, newest first.
pub async fn get(
server: &Server,
mut request: GetRequest<HoldExport>,
) -> trc::Result<GetResponse<HoldExport>> {
let properties = request.unwrap_properties(ALL);
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
let mut response = GetResponse {
account_id: request.account_id.into(),
state: None,
list: Vec::new(),
not_found,
};
let mut exports = hold::exports(server.store()).await?;
exports.reverse();
match ids {
None => response
.list
.extend(exports.iter().map(|e| to_value(e, &properties))),
Some(ids) => {
for id in ids {
match exports.iter().find(|e| u64::from(e.id) == id.id()) {
Some(export) => response.list.push(to_value(export, &properties)),
None => response.push_not_found(id),
}
}
}
}
Ok(response)
}
fn invalid(property: P, why: &str) -> SetError<P> {
SetError::invalid_properties()
.with_property(property)
.with_description(why.to_string())
}
/// `inbuxa:HoldExport/set`: create starts an export; nothing else is
/// allowed. The request layer records it with its reason.
pub async fn set(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, HoldExport>,
) -> trc::Result<SetResponse<HoldExport>> {
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
let arguments: HoldExportSetArguments = std::mem::take(&mut request.arguments);
let data = server.store();
let actor = server.audit_actor(access_token).await;
'create: for (client_id, value) in request.unwrap_create() {
let mut hold_id = None;
let mut accounts = Vec::new();
let mut reason = arguments.reason.clone();
for (key, value) in value.into_expanded_object() {
match (&key, &value) {
(Key::Property(P::HoldId), Value::Str(id)) => {
hold_id = Id::from_str(id).ok().and_then(|id| u32::try_from(id.id()).ok())
}
(Key::Property(P::AccountIds), Value::Array(items)) => {
for item in items {
match item {
Value::Str(id) => match Id::from_str(id) {
Ok(id) => accounts.push(id.document_id()),
Err(_) => {
response.not_created.append(
client_id,
invalid(P::AccountIds, "accountIds must be account ids."),
);
continue 'create;
}
},
_ => {
response.not_created.append(
client_id,
invalid(P::AccountIds, "accountIds must be account ids."),
);
continue 'create;
}
}
}
}
(Key::Property(P::Reason), Value::Str(r)) => reason = Some(r.to_string()),
_ => {
response.not_created.append(
client_id,
SetError::invalid_properties().with_property(key.clone().into_owned()),
);
continue 'create;
}
}
}
let Some(reason) = reason
.map(|r| r.trim().chars().take(500).collect::<String>())
.filter(|r| !r.is_empty())
else {
response.not_created.append(
client_id,
invalid(P::Reason, "Say why: a reason is required and is kept in the audit log."),
);
continue;
};
let hold = match hold_id {
Some(id) => hold::get(data, id).await?,
None => None,
};
let Some(hold) = hold else {
response
.not_created
.append(client_id, invalid(P::HoldId, "No such legal hold."));
continue;
};
if !hold.is_active() {
response.not_created.append(
client_id,
invalid(P::HoldId, "That hold was released; export while a hold is in place."),
);
continue;
}
let Some(created_by_id) = actor.account_id else {
response
.not_created
.append(client_id, SetError::forbidden().with_description("Sign in as a person to export."));
continue;
};
accounts.sort_unstable();
accounts.dedup();
let export = Export {
id: 0,
hold_id: hold.id,
accounts,
reason,
created_at: now(),
created_by: actor.name.clone(),
created_by_id,
status: ExportStatus::Running,
finished_at: None,
blob_id: None,
size: 0,
items: 0,
sha256: None,
error: None,
};
let id = hold::create_export(data, &export).await?;
let export = Export { id, ..export };
// The collection runs on its own; get says when it's ready
let server = server.clone();
tokio::spawn(async move {
let mut done = export.clone();
match crate::inbuxa::hold_export::build(&server, &hold, &export.accounts).await {
Ok((bytes, items)) => match server.put_jmap_blob(export.created_by_id, &bytes).await {
Ok(blob) => {
done.status = ExportStatus::Ready;
done.blob_id = Some(blob.to_string());
done.size = bytes.len() as u64;
done.items = items as u64;
done.sha256 = Some(
Sha256::digest(&bytes).iter().map(|b| format!("{b:02x}")).collect(),
);
}
Err(err) => {
done.status = ExportStatus::Failed;
done.error = Some(err.to_string());
}
},
Err(err) => {
done.status = ExportStatus::Failed;
done.error = Some(
err.value_as_str(trc::Key::Details)
.map(str::to_string)
.unwrap_or_else(|| err.to_string()),
);
}
}
done.finished_at = Some(now());
if let Err(err) = hold::update_export(server.store(), &done).await {
trc::error!(err.details("Failed to save a legal hold export's result"));
}
});
let mut out = Map::with_capacity(1);
out.insert_unchecked(
Key::Property(P::Id),
Value::Element(HoldExportValue::Id(Id::from(id))),
);
response.created.insert(client_id, Value::Object(out));
}
for (id, _) in request.unwrap_update() {
response.not_updated.append(
id,
SetError::forbidden().with_description("An export can't be changed; start a new one."),
);
}
for id in request.unwrap_destroy() {
response.not_destroyed.append(
id,
SetError::forbidden().with_description("Exports stay listed; the file expires on its own."),
);
}
Ok(response)
}
+2
View File
@@ -10,6 +10,8 @@
pub mod access; pub mod access;
pub mod account_lock; pub mod account_lock;
pub mod legal_hold; pub mod legal_hold;
pub mod hold_export;
pub mod hold_export_api;
pub mod audit; pub mod audit;
pub mod audit_log; pub mod audit_log;
pub mod ai_limits; pub mod ai_limits;
+105 -23
View File
@@ -26,7 +26,9 @@ use common::{
}; };
use inbuxa_features::security::{ use inbuxa_features::security::{
listeners, listeners,
protocol_policy::{LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener}, protocol_policy::{
LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener, Switches,
},
}; };
use jmap_proto::{ use jmap_proto::{
error::set::SetError, error::set::SetError,
@@ -48,6 +50,9 @@ type PValue = Value<'static, P, ProtocolPolicyValue>;
const ALL: &[P] = &[ const ALL: &[P] = &[
P::Id, P::Id,
P::LegacyProtocols, P::LegacyProtocols,
P::Imap,
P::Pop3,
P::ManageSieve,
P::CloseSubmission, P::CloseSubmission,
P::SavedListeners, P::SavedListeners,
P::ChangedAt, P::ChangedAt,
@@ -91,22 +96,49 @@ fn listener_value(listener: &SavedListener) -> PValue {
Value::Object(out) Value::Object(out)
} }
/// A switch as JMAP spells it.
pub(crate) fn switch_str(value: LegacyProtocols) -> &'static str {
match value {
LegacyProtocols::Enabled => "enabled",
LegacyProtocols::Disabled => "disabled",
}
}
/// A switch from JMAP.
pub(crate) fn parse_switch(value: Option<&str>) -> Result<LegacyProtocols, String> {
match value {
Some("enabled") => Ok(LegacyProtocols::Enabled),
Some("disabled") => Ok(LegacyProtocols::Disabled),
_ => Err(r#"must be "enabled" or "disabled""#.to_string()),
}
}
/// The JMAP name of a per-protocol switch property.
pub(crate) fn switch_name(property: &P) -> Option<&'static str> {
match property {
P::Imap => Some("imap"),
P::Pop3 => Some("pop3"),
P::ManageSieve => Some("manageSieve"),
_ => None,
}
}
fn to_value( fn to_value(
policy: &Policy, policy: &Policy,
would_close: &[SavedListener], would_close: &[SavedListener],
recent: &[RecentUse], recent: &[RecentUse],
properties: &[P], properties: &[P],
) -> PValue { ) -> PValue {
let mut policy = policy.clone();
policy.normalize();
let policy = &policy;
let mut out = Map::with_capacity(properties.len()); let mut out = Map::with_capacity(properties.len());
for property in properties { for property in properties {
let value = match property { let value = match property {
P::Id => Value::Element(ProtocolPolicyValue::Id(Id::singleton())), P::Id => Value::Element(ProtocolPolicyValue::Id(Id::singleton())),
P::LegacyProtocols => Value::Str( P::LegacyProtocols => Value::Str(switch_str(policy.legacy_protocols).into()),
match policy.legacy_protocols { P::Imap | P::Pop3 | P::ManageSieve => Value::Str(
LegacyProtocols::Enabled => "enabled", switch_str(policy.switch(switch_name(property).unwrap_or_default())).into(),
LegacyProtocols::Disabled => "disabled",
}
.into(),
), ),
P::CloseSubmission => Value::Bool(policy.close_submission), P::CloseSubmission => Value::Bool(policy.close_submission),
P::SavedListeners => Value::Array( P::SavedListeners => Value::Array(
@@ -176,10 +208,11 @@ where
) )
} }
/// The listeners turning the switch on would close, whatever it is now. /// The listeners turning every protocol off would close, whatever the switches
/// are now; each names its protocol, so the console shows one protocol's.
async fn would_close(server: &Server, policy: &Policy) -> trc::Result<Vec<SavedListener>> { async fn would_close(server: &Server, policy: &Policy) -> trc::Result<Vec<SavedListener>> {
let mut hypothetical = policy.clone(); let mut hypothetical = policy.clone();
hypothetical.legacy_protocols = LegacyProtocols::Disabled; hypothetical.set_all(LegacyProtocols::Disabled);
hypothetical.apply_locks(); hypothetical.apply_locks();
listeners::would_close(server.registry(), &hypothetical).await listeners::would_close(server.registry(), &hypothetical).await
} }
@@ -238,12 +271,12 @@ fn apply(
value: &Value<'_, P, ProtocolPolicyValue>, value: &Value<'_, P, ProtocolPolicyValue>,
) -> Result<(), String> { ) -> Result<(), String> {
match property { match property {
P::LegacyProtocols => { // The kill-all sets all three; a protocol named in the same /set is
policy.legacy_protocols = match value.as_str().as_deref() { // applied after it (see `set`), so it wins.
Some("enabled") => LegacyProtocols::Enabled, P::LegacyProtocols => policy.set_all(parse_switch(value.as_str().as_deref())?),
Some("disabled") => LegacyProtocols::Disabled, P::Imap | P::Pop3 | P::ManageSieve => {
_ => return Err(r#"must be "enabled" or "disabled""#.to_string()), let value = parse_switch(value.as_str().as_deref())?;
} policy.set(switch_name(property).unwrap_or_default(), value);
} }
P::CloseSubmission => { P::CloseSubmission => {
policy.close_submission = value policy.close_submission = value
@@ -262,7 +295,13 @@ fn apply(
/// Puts a property back to its default (a `null` in `/set`). /// Puts a property back to its default (a `null` in `/set`).
fn reset(policy: &mut Policy, property: &P, defaults: &Policy) -> Result<(), String> { fn reset(policy: &mut Policy, property: &P, defaults: &Policy) -> Result<(), String> {
match property { match property {
P::LegacyProtocols => policy.legacy_protocols = defaults.legacy_protocols, P::LegacyProtocols => policy.set_all(defaults.legacy_protocols),
P::Imap | P::Pop3 | P::ManageSieve => {
policy.set(
switch_name(property).unwrap_or_default(),
LegacyProtocols::Enabled,
);
}
P::CloseSubmission => policy.close_submission = defaults.close_submission, P::CloseSubmission => policy.close_submission = defaults.close_submission,
P::Id => return Err("is immutable".to_string()), P::Id => return Err("is immutable".to_string()),
other if other.is_server_set() => return Err("is set by the server".to_string()), other if other.is_server_set() => return Err("is set by the server".to_string()),
@@ -312,10 +351,14 @@ pub async fn set(
} }
let mut policy = server.protocol_policy().await?; let mut policy = server.protocol_policy().await?;
policy.normalize();
let defaults = Policy::default(); let defaults = Policy::default();
let mut error = None; let mut error = None;
for (key, value) in value.into_expanded_object() { // The kill-all first, so a protocol named beside it overrides it.
let mut entries: Vec<_> = value.into_expanded_object().collect();
entries.sort_by_key(|(key, _)| !matches!(key, Key::Property(P::LegacyProtocols)));
for (key, value) in entries {
let Key::Property(property) = &key else { let Key::Property(property) = &key else {
error = Some(SetError::invalid_properties().with_property(key.into_owned())); error = Some(SetError::invalid_properties().with_property(key.into_owned()));
break; break;
@@ -393,21 +436,30 @@ fn listener_refusal(policy: &Policy, listener: &NetworkListener) -> Option<(Prop
let protocol = listeners::protocol_name(listener.protocol); let protocol = listeners::protocol_name(listener.protocol);
// A submission listener closes because of its port, not its protocol // A submission listener closes because of its port, not its protocol
// (LP-3), so the port is what would have to change. // (LP-3), so the port is what would have to change.
let property = if protocol == "smtp" { let (property, what) = if protocol == "smtp" {
Property::Bind (Property::Bind, "Legacy mail protocols are".to_string())
} else { } else {
Property::Protocol (Property::Protocol, format!("{} is", display_name(protocol)))
}; };
Some(( Some((
property, property,
format!( format!(
"Legacy mail protocols are off (inbuxa:ProtocolPolicy), and this {protocol} \ "{what} off (inbuxa:ProtocolPolicy), and this {protocol} listener would reopen \
listener would reopen a port the switch keeps closed. Turn legacy protocols \ a port the switch keeps closed. Turn it back on first."
back on first."
), ),
)) ))
} }
/// A protocol's name as people read it.
fn display_name(protocol: &str) -> &str {
match protocol {
"imap" => "IMAP",
"pop3" => "POP3",
"manageSieve" => "ManageSieve",
other => other,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -461,6 +513,36 @@ mod tests {
} }
} }
#[test]
fn one_protocol_off_refuses_only_its_listeners() {
let mut policy = Policy::default();
policy.set("pop3", LegacyProtocols::Disabled);
policy.normalize();
let (property, why) = listener_refusal(
&policy,
&listener(NetworkListenerProtocol::Pop3, "[::]:995"),
)
.expect("refused");
assert_eq!(property, Property::Protocol);
assert!(why.starts_with("POP3 is off"), "{why}");
assert!(
listener_refusal(
&policy,
&listener(NetworkListenerProtocol::Imap, "[::]:993")
)
.is_none()
);
}
#[test]
fn a_switch_reads_and_parses_as_jmap_spells_it() {
assert_eq!(switch_str(LegacyProtocols::Disabled), "disabled");
assert_eq!(parse_switch(Some("enabled")), Ok(LegacyProtocols::Enabled));
assert!(parse_switch(Some("off")).is_err());
assert_eq!(switch_name(&P::ManageSieve), Some("manageSieve"));
assert_eq!(switch_name(&P::CloseSubmission), None);
}
#[test] #[test]
fn off_still_allows_what_the_switch_never_closes() { fn off_still_allows_what_the_switch_never_closes() {
// Locked (LP-21) and inbound (LP-3): the switch doesn't close them, // Locked (LP-21) and inbound (LP-3): the switch doesn't close them,
@@ -12,16 +12,22 @@
//! with no ids answers with it, and any other id is `notFound`. At server //! with no ids answers with it, and any other id is `notFound`. At server
//! level, `/get` with no ids answers with every tenant's. //! level, `/get` with no ids answers with every tenant's.
//! //!
//! Turning it off never needs the server's leave; turning it back on is //! Each of IMAP, POP3 and ManageSieve has its own switch, and
//! refused with `forbidden` while the server has legacy protocols off (LP-9). //! `legacyProtocols` is the kill-all, as on the server's policy. Turning one
//! off never needs the server's leave; turning one back on is refused with
//! `forbidden` while the server has that protocol off (LP-9).
//! A tenant's switch closes no port (LP-13) -- sign-in and client //! A tenant's switch closes no port (LP-13) -- sign-in and client
//! configuration read it (LP-10, LP-14a). //! configuration read it (LP-10, LP-14a).
use crate::inbuxa::protocol_policy::recent_value; use crate::inbuxa::protocol_policy::{parse_switch, recent_value, switch_str};
use common::{Server, auth::AccessToken, network::legacy::RecentUse}; use common::{
Server,
auth::AccessToken,
network::legacy::{RecentUse, switches_value},
};
use inbuxa_features::{ use inbuxa_features::{
security::{ security::{
protocol_policy::LegacyProtocols, protocol_policy::{LegacyProtocols, SWITCHED, Switches},
tenant_protocol_policy::{self, TenantProtocolPolicy as Policy, refusal}, tenant_protocol_policy::{self, TenantProtocolPolicy as Policy, refusal},
}, },
tenancy::quota::all_tenants, tenancy::quota::all_tenants,
@@ -46,6 +52,9 @@ const ALL: &[P] = &[
P::Id, P::Id,
P::TenantId, P::TenantId,
P::LegacyProtocols, P::LegacyProtocols,
P::Imap,
P::Pop3,
P::ManageSieve,
P::ChangedAt, P::ChangedAt,
P::ChangedBy, P::ChangedBy,
P::RecentLegacyUse, P::RecentLegacyUse,
@@ -60,19 +69,29 @@ async fn reachable(server: &Server, access_token: &AccessToken) -> trc::Result<V
} }
} }
/// The JMAP name of a per-protocol switch property.
fn switch_name(property: &P) -> Option<&'static str> {
match property {
P::Imap => Some("imap"),
P::Pop3 => Some("pop3"),
P::ManageSieve => Some("manageSieve"),
_ => None,
}
}
fn to_value(tenant_id: u32, policy: &Policy, recent: &[RecentUse], properties: &[P]) -> PValue { fn to_value(tenant_id: u32, policy: &Policy, recent: &[RecentUse], properties: &[P]) -> PValue {
let mut policy = policy.clone();
policy.normalize();
let policy = &policy;
let mut out = Map::with_capacity(properties.len()); let mut out = Map::with_capacity(properties.len());
for property in properties { for property in properties {
let value = match property { let value = match property {
P::Id | P::TenantId => { P::Id | P::TenantId => {
Value::Element(TenantProtocolPolicyValue::Id(Id::from(tenant_id))) Value::Element(TenantProtocolPolicyValue::Id(Id::from(tenant_id)))
} }
P::LegacyProtocols => Value::Str( P::LegacyProtocols => Value::Str(switch_str(policy.legacy_protocols).into()),
match policy.legacy_protocols { P::Imap | P::Pop3 | P::ManageSieve => Value::Str(
LegacyProtocols::Enabled => "enabled", switch_str(policy.switch(switch_name(property).unwrap_or_default())).into(),
LegacyProtocols::Disabled => "disabled",
}
.into(),
), ),
P::ChangedAt => policy P::ChangedAt => policy
.changed_at .changed_at
@@ -165,29 +184,41 @@ pub async fn set(
let data = &server.core.storage.data; let data = &server.core.storage.data;
let previous = tenant_protocol_policy::get(data, tenant_id).await?; let previous = tenant_protocol_policy::get(data, tenant_id).await?;
let mut policy = previous.clone(); let mut policy = previous.clone();
policy.normalize();
let mut error = None; let mut error = None;
for (key, value) in value.into_expanded_object() { // What this request sets to `enabled`, for LP-9.
let mut turned_on: Vec<&'static str> = Vec::new();
// The kill-all first, so a protocol named beside it overrides it.
let mut entries: Vec<_> = value.into_expanded_object().collect();
entries.sort_by_key(|(key, _)| !matches!(key, Key::Property(P::LegacyProtocols)));
for (key, value) in entries {
// `null` puts a switch back to its default, on.
let parsed = match value {
Value::Null => Ok(LegacyProtocols::Enabled),
value => parse_switch(value.as_str().as_deref()),
};
let result = match &key { let result = match &key {
Key::Property(P::LegacyProtocols) => match value { Key::Property(P::LegacyProtocols) => parsed.map(|value| {
Value::Null => { policy.set_all(value);
policy.legacy_protocols = LegacyProtocols::Enabled; if !value.is_disabled() {
Ok(()) turned_on.extend(SWITCHED.iter().copied());
} else {
turned_on.clear();
} }
value => match value.as_str().as_deref() { }),
Some("enabled") => { Key::Property(property @ (P::Imap | P::Pop3 | P::ManageSieve)) => {
policy.legacy_protocols = LegacyProtocols::Enabled; parsed.map(|value| {
Ok(()) let name = switch_name(property).unwrap_or_default();
policy.set(name, value);
turned_on.retain(|p| *p != name);
if !value.is_disabled() {
turned_on.push(name);
} }
Some("disabled") => { })
policy.legacy_protocols = LegacyProtocols::Disabled; }
Ok(()) Key::Property(P::Id) => Err("is immutable".to_string()),
} Key::Property(_) => Err("is set by the server".to_string()),
_ => Err(r#"must be "enabled" or "disabled""#), _ => Err("is not a property of inbuxa:TenantProtocolPolicy".to_string()),
},
},
Key::Property(P::Id) => Err("is immutable"),
Key::Property(_) => Err("is set by the server"),
_ => Err("is not a property of inbuxa:TenantProtocolPolicy"),
}; };
if let Err(why) = result { if let Err(why) = result {
error = Some( error = Some(
@@ -203,15 +234,18 @@ pub async fn set(
continue; continue;
} }
// LP-9: server off means off for everyone. // LP-9: server off means off for everyone, protocol by protocol.
if let Some(why) = refusal(&server.protocol_policy().await?, policy.legacy_protocols) { if let Some(why) = refusal(&server.protocol_policy().await?, &turned_on) {
response response
.not_updated .not_updated
.append(id, SetError::forbidden().with_description(why)); .append(id, SetError::forbidden().with_description(why));
continue; continue;
} }
if policy.legacy_protocols != previous.legacy_protocols { policy.normalize();
let mut before = previous;
before.normalize();
if policy.off() != before.off() {
policy.changed_at = Some(store::write::now() * 1000); policy.changed_at = Some(store::write::now() * 1000);
policy.changed_by = Some(Id::from(access_token.account_id()).to_string()); policy.changed_by = Some(Id::from(access_token.account_id()).to_string());
tenant_protocol_policy::set(data, tenant_id, &policy).await?; tenant_protocol_policy::set(data, tenant_id, &policy).await?;
@@ -221,11 +255,7 @@ pub async fn set(
Security(trc::SecurityEvent::LegacyProtocolsChanged), Security(trc::SecurityEvent::LegacyProtocolsChanged),
Policy = "tenant", Policy = "tenant",
Id = tenant_id, Id = tenant_id,
Value = if policy.legacy_protocols.is_disabled() { Value = switches_value(&policy),
"disabled"
} else {
"enabled"
},
AccountId = policy.changed_by.clone(), AccountId = policy.changed_by.clone(),
); );
} }
+276 -31
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* 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::{
@@ -13,6 +15,8 @@ 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,
@@ -83,16 +87,15 @@ 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 = match self let (mx_lookup, forced_insecure) = match validated_lookup(
.core &self.core.smtp.resolvers.dnssec.resolver,
.smtp self.core.smtp.resolvers.dns.resolver(),
.resolvers Name::from_str_relaxed::<&str>(key.as_ref())?,
.dnssec RecordType::MX,
.resolver )
.mx_lookup(Name::from_str_relaxed::<&str>(key.as_ref())?) .await
.await
{ {
Ok(mx_lookup) => mx_lookup, Ok(validated) => (validated.lookup, validated.insecure),
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
@@ -144,7 +147,11 @@ impl TlsaLookup for Server {
.collect::<Arc<[MX]>>(); .collect::<Arc<[MX]>>();
let records = RecordSet { let records = RecordSet {
rrset, rrset,
dnssec_status: dnssec_status.unwrap_or(DnssecStatus::Indeterminate), dnssec_status: if forced_insecure {
DnssecStatus::Insecure
} else {
dnssec_status.unwrap_or(DnssecStatus::Indeterminate)
},
}; };
self.inner self.inner
@@ -285,16 +292,15 @@ 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 = match self let (lookup, forced_insecure) = match validated_lookup(
.core &self.core.smtp.resolvers.dnssec.resolver,
.smtp self.core.smtp.resolvers.dns.resolver(),
.resolvers name.clone(),
.dnssec RecordType::A,
.resolver )
.ipv4_lookup(name.clone()) .await
.await
{ {
Ok(lookup) => lookup, Ok(validated) => (validated.lookup, validated.insecure),
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
@@ -325,7 +331,11 @@ impl TlsaLookup for Server {
_ => None, _ => None,
}) })
.collect::<Arc<[Ipv4Addr]>>(), .collect::<Arc<[Ipv4Addr]>>(),
dnssec_status: tlsa_base_status(&name, answers, RecordType::A), dnssec_status: if forced_insecure {
DnssecStatus::Insecure
} else {
tlsa_base_status(&name, answers, RecordType::A)
},
}; };
self.inner self.inner
@@ -363,16 +373,15 @@ 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 = match self let (lookup, forced_insecure) = match validated_lookup(
.core &self.core.smtp.resolvers.dnssec.resolver,
.smtp self.core.smtp.resolvers.dns.resolver(),
.resolvers name.clone(),
.dnssec RecordType::AAAA,
.resolver )
.ipv6_lookup(name.clone()) .await
.await
{ {
Ok(lookup) => lookup, Ok(validated) => (validated.lookup, validated.insecure),
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
@@ -403,7 +412,11 @@ impl TlsaLookup for Server {
_ => None, _ => None,
}) })
.collect::<Arc<[Ipv6Addr]>>(), .collect::<Arc<[Ipv6Addr]>>(),
dnssec_status: tlsa_base_status(&name, answers, RecordType::AAAA), dnssec_status: if forced_insecure {
DnssecStatus::Insecure
} else {
tlsa_base_status(&name, answers, RecordType::AAAA)
},
}; };
self.inner self.inner
@@ -415,6 +428,115 @@ 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,
@@ -511,7 +633,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}; use mail_auth::hickory_resolver::proto::rr::rdata::{A, CNAME, SOA};
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
fn name(value: &str) -> Name { fn name(value: &str) -> Name {
@@ -624,4 +746,127 @@ 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());
}
} }
+57 -2
View File
@@ -26,7 +26,10 @@ use mail_auth::{
common::verify::VerifySignature, common::verify::VerifySignature,
dkim2::Dkim2Output, dkim2::Dkim2Output,
dmarc::{self}, dmarc::{self},
report::{AuthFailureType, IdentityAlignment, PolicyPublished, Record, SPFDomainScope}, report::{
ActionDisposition, AuthFailureType, IdentityAlignment, PolicyPublished, Record, Report,
SPFDomainScope,
},
}; };
use registry::{ use registry::{
schema::{ schema::{
@@ -459,7 +462,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 _ = mail_auth::report::Report::from(report.report).write_rfc5322( let _ = with_compatible_dispositions(Report::from(report.report)).write_rfc5322(
&self &self
.eval_if( .eval_if(
&self.core.smtp.report.submitter, &self.core.smtp.report.submitter,
@@ -710,3 +713,55 @@ 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" "2026.9.28.3"
}; };
} }
Binary file not shown.
+1
View File
@@ -86,6 +86,7 @@ dns-update = { version = "0.5", features = ["test_provider"] }
x509-parser = "0.18" x509-parser = "0.18"
rcgen = "0.14" rcgen = "0.14"
sha2 = "0.11" sha2 = "0.11"
zip = "8.6" # inbuxa: reading legal hold exports
time = "0.3" time = "0.3"
testcontainers = { version = "0.28", features = ["reusable-containers"] } testcontainers = { version = "0.28", features = ["reusable-containers"] }
rust-s3 = { version = "0.37", default-features = false, features = ["tokio-rustls-tls"] } rust-s3 = { version = "0.37", default-features = false, features = ["tokio-rustls-tls"] }
+101
View File
@@ -34,6 +34,12 @@ account which way its switches point (test 13), and the impact panel's
list names who signed in over what: every account at server scope, only the list names who signed in over what: every account at server scope, only the
tenant's own at tenant scope, rewritten at most once an hour (LP-15). tenant's own at tenant scope, rewritten at most once an hour (LP-15).
Then one switch per protocol: POP3 alone off closes only POP3's port and
refuses only POP3 sign-in, sending and IMAP go on, and only POP3 stops being
advertised; one /set can close one protocol and reopen another. And a
tenant turning POP3 off for itself, and not able to turn IMAP back on while
the server has IMAP off.
Passwords are generated into files under target/e2e and never printed. Passwords are generated into files under target/e2e and never printed.
Everything is removed afterwards unless KEEP=1. Everything is removed afterwards unless KEEP=1.
""" """
@@ -380,6 +386,37 @@ def tenant_checks(admin, admin_pw, account):
"and its user signs in over IMAP again") "and its user signs in over IMAP again")
check(session_flag(tu, user_pw) == "enabled", "and its session says enabled again (test 13)") check(session_flag(tu, user_pw) == "enabled", "and its session says enabled again (test 13)")
# One protocol at a time, for a tenant.
tone = lambda update: one(ta, tadmin_pw, "inbuxa:TenantProtocolPolicy/set",
{"accountId": tacct, "update": {t: update}})
res = tone({"pop3": "disabled"})
check(t in (res[1].get("updated") or {}), "a tenant admin turns POP3 alone off")
check(pop3_login(PORTS["pop3"], tu, user_pw) ==
"-ERR [AUTH] Your organization allows only inbuxa webmail and JMAP apps. "
"This mail app can't sign in.", "the tenant's user is refused over POP3")
check(imap_login(PORTS["imap"], tu, user_pw).startswith("OK"),
"and still signs in over IMAP")
check(smtp_auths(PORTS["submissions"], tu, [user_pw])[0].startswith("235"),
"and still sends")
check(pop3_login(PORTS["pop3"], admin, admin_pw).startswith("+OK"),
"an account outside the tenant still signs in over POP3")
check(session_allowed(tu, user_pw) == ["imap", "manageSieve", "submission"],
"the tenant user's session leaves POP3 out")
one(admin, admin_pw, "inbuxa:ProtocolPolicy/set",
{"accountId": account, "update": {"singleton": {"imap": "disabled"}}})
res = tone({"imap": "enabled"})
refused = (res[1].get("notUpdated") or {}).get(t) or {}
check(refused.get("type") == "forbidden"
and (refused.get("description") or "").startswith("IMAP is off"),
"with IMAP off server-wide, the tenant can't turn IMAP on, and is told which (LP-9)")
res = tone({"pop3": "enabled"})
check(t in (res[1].get("updated") or {}), "but it can turn its own POP3 back on")
check(session_allowed(tu, user_pw) == ["pop3", "manageSieve", "submission"],
"the session follows: IMAP off by the server, POP3 back")
one(admin, admin_pw, "inbuxa:ProtocolPolicy/set",
{"accountId": account, "update": {"singleton": {"imap": "enabled"}}})
check(settle(PORTS["imap"], True), "IMAP back after the server's switch returns")
# A deleted tenant's switch goes with it, so a tenant that later gets the # A deleted tenant's switch goes with it, so a tenant that later gets the
# same id doesn't start with legacy protocols off. # same id doesn't start with legacy protocols off.
sget = lambda ids: one(admin, admin_pw, "inbuxa:TenantProtocolPolicy/get", sget = lambda ids: one(admin, admin_pw, "inbuxa:TenantProtocolPolicy/get",
@@ -406,6 +443,67 @@ def session_flag(user, password):
return sess["accounts"][acct]["accountCapabilities"].get(INBUXA, {}).get("legacyProtocols") return sess["accounts"][acct]["accountCapabilities"].get(INBUXA, {}).get("legacyProtocols")
def session_allowed(user, password):
"""legacyAllowed from the account's urn:inbuxa:jmap capability."""
sess = session(user, password)
acct = sess["primaryAccounts"].get(INBUXA) or list(sess["accounts"])[0]
return sess["accounts"][acct]["accountCapabilities"].get(INBUXA, {}).get("legacyAllowed")
def per_protocol_checks(admin, admin_pw, account):
"""One switch per protocol, server-wide."""
pset = lambda update: one(admin, admin_pw, "inbuxa:ProtocolPolicy/set",
{"accountId": account, "update": {"singleton": update}})
pget = lambda: one(admin, admin_pw, "inbuxa:ProtocolPolicy/get",
{"accountId": account, "ids": None})[1]["list"][0]
changes = len(events("security.legacy-protocols-changed"))
res = pset({"pop3": "disabled"})
check("singleton" in (res[1].get("updated") or {}), "POP3 alone can be turned off")
check(settle(PORTS["pop3"], False), "POP3 stopped accepting")
check(accepts(PORTS["imap"]), "IMAP still accepts with only POP3 off")
policy = pget()
check((policy["imap"], policy["pop3"], policy["manageSieve"], policy["legacyProtocols"])
== ("enabled", "disabled", "enabled", "enabled"),
"the switches read back: POP3 off, the rest on, the kill-all not set")
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
"IMAP sign-in works with only POP3 off")
check(smtp_auths(PORTS["submissions"], admin, [admin_pw])[0].startswith("235"),
"submission sign-in works: sending goes on while any protocol is allowed")
check(session_allowed(admin, admin_pw) == ["imap", "manageSieve", "submission"],
"the session lists what is still allowed")
check(session_flag(admin, admin_pw) == "enabled",
"and the old legacyProtocols flag still says enabled")
during = advertised(admin, admin_pw)
check(during["autoconfig"] == {"imap", "smtp"}, "autoconfig drops POP3 only")
check("pop3" not in during["pacc"] and {"imap", "smtp"} <= during["pacc"],
"PACC drops POP3 only")
check(during["srv"].get("_pop3s._tcp") == "." and during["srv"].get("_imaps._tcp") != ".",
"the zone marks POP3 not offered and still offers IMAP")
changed = events("security.legacy-protocols-changed")[changes:]
check(len(changed) == 1 and 'value = "pop3 disabled"' in changed[0]
and 'details = "closed"' in changed[0],
"turning POP3 off is one event naming it")
if len(changed) != 1:
print(" events:", changed)
# One /set can close one protocol and bring another back.
pset({"pop3": "enabled", "imap": "disabled"})
check(settle(PORTS["imap"], False), "IMAP closes in the same change")
check(settle(PORTS["pop3"], True), "that brings POP3 back")
check(pop3_login(PORTS["pop3"], admin, admin_pw).startswith("+OK"),
"POP3 sign-in works again")
changed = events("security.legacy-protocols-changed")[changes + 1:]
check(len(changed) == 1 and 'details = "closed and reopened"' in changed[0],
"and it is one event, closed and reopened")
pset({"imap": "enabled"})
check(settle(PORTS["imap"], True), "IMAP back on")
policy = pget()
check(not policy["savedListeners"] and policy["legacyProtocols"] == "enabled",
"nothing left saved once every protocol is on")
def events_matching(name, *parts): def events_matching(name, *parts):
return any(all(p in line for p in parts) for line in events(name)) return any(all(p in line for p in parts) for line in events(name))
@@ -636,6 +734,9 @@ def main():
check(after["autoconfig"] == before["autoconfig"] and after["srv"] == before["srv"], check(after["autoconfig"] == before["autoconfig"] and after["srv"] == before["srv"],
"autoconfig and the suggested zone offer them again once back on") "autoconfig and the suggested zone offer them again once back on")
# One switch per protocol.
per_protocol_checks(admin, admin_pw, account)
# A tenant's own switch (LP-9 to LP-14a). # A tenant's own switch (LP-9 to LP-14a).
tenant_checks(admin, admin_pw, account) tenant_checks(admin, admin_pw, account)
+4 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* 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::{
@@ -174,7 +176,8 @@ 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);
assert_eq!(record.action_disposition(), ActionDisposition::Pass); // inbuxa: "pass" goes out as "none" for RFC 7489 parsers
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]");
+116
View File
@@ -436,6 +436,96 @@ pub async fn test(test: &mut TestServer) {
names.sort_unstable(); names.sort_unstable();
assert_eq!(names, vec!["Matter 7001", "Matter 7002"], "LH-14: {response}"); assert_eq!(names, vec!["Matter 7001", "Matter 7002"], "LH-14: {response}");
// LH-12: collect what the hold keeps, as a ZIP with its manifest
import(&frozen_client, "Still in the inbox", None).await;
let (_, response) = admin
.hold_call(
"inbuxa:HoldExport/set",
json!({"create": {"x": {"holdId": first, "accountIds": [frozen.id_string()]}}}),
)
.await;
assert_eq!(
response["notCreated"]["x"]["type"], "invalidProperties",
"AU-12: an export without a reason: {response}"
);
let (_, response) = admin
.hold_call(
"inbuxa:HoldExport/set",
json!({"reason": "Production to opposing counsel",
"create": {"x": {"holdId": first,
"accountIds": [frozen.id_string(), admin.id_string()]}}}),
)
.await;
let export_id = response["created"]["x"]["id"]
.as_str()
.unwrap_or_else(|| panic!("LH-12: not started: {response}"))
.to_string();
let mut export = Value::Null;
for _ in 0..60 {
let (_, got) = admin
.hold_call("inbuxa:HoldExport/get", json!({"ids": [export_id]}))
.await;
export = got["list"][0].clone();
if export["status"] != "running" {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
assert_eq!(export["status"], "ready", "LH-12: {export}");
let bytes = admin
.jmap_client()
.await
.download(export["blobId"].as_str().unwrap())
.await
.unwrap();
assert_eq!(export["size"].as_u64(), Some(bytes.len() as u64), "{export}");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let names = (0..zip.len())
.map(|i| zip.by_index(i).unwrap().name().to_string())
.collect::<Vec<_>>();
assert!(
names.iter().any(|n| n.starts_with("[email protected]/mail/") && n.ends_with(".eml")),
"LH-12: live mail missing: {names:?}"
);
assert!(
names.iter().any(|n| n.starts_with("[email protected]/archived/email/")),
"LH-12: the kept deleted mail is missing: {names:?}"
);
assert!(
names
.iter()
.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:?}"
);
let mut manifest = String::new();
std::io::Read::read_to_string(&mut zip.by_name("manifest.csv").unwrap(), &mut manifest).unwrap();
let mut hash = String::new();
std::io::Read::read_to_string(&mut zip.by_name("manifest.sha256").unwrap(), &mut hash).unwrap();
use sha2::Digest;
let expected: String = sha2::Sha256::digest(manifest.as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect();
assert!(hash.starts_with(&expected), "LH-12: the manifest's hash doesn't match");
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
let (_, response) = frozen
.hold_call(
"inbuxa:HoldExport/set",
json!({"reason": "Mine", "create": {"x": {"holdId": first}}}),
)
.await;
assert!(
response.to_string().contains("forbidden") && response["created"].is_null(),
"LH-13: a user exported a hold: {response}"
);
let (_, response) = frozen let (_, response) = frozen
.hold_call("x:ArchivedItem/set", json!({"destroy": [item_id]})) .hold_call("x:ArchivedItem/set", json!({"destroy": [item_id]}))
.await; .await;
@@ -470,6 +560,16 @@ pub async fn test(test: &mut TestServer) {
.await; .await;
let item = archived(frozen.archived_items().await); let item = archived(frozen.archived_items().await);
assert!(!is_held(&item), "LH-10: the last release left it held: {item}"); assert!(!is_held(&item), "LH-10: the last release left it held: {item}");
let (_, response) = admin
.hold_call(
"inbuxa:HoldExport/set",
json!({"reason": "Too late", "create": {"x": {"holdId": first}}}),
)
.await;
assert_eq!(
response["notCreated"]["x"]["type"], "invalidProperties",
"LH-12: a released hold was exported: {response}"
);
let until = item["archivedUntil"].as_str().unwrap_or_default().to_string(); let until = item["archivedUntil"].as_str().unwrap_or_default().to_string();
let grace = chrono::Utc::now() + chrono::Duration::days(29); let grace = chrono::Utc::now() + chrono::Duration::days(29);
assert!( assert!(
@@ -574,6 +674,22 @@ pub async fn test(test: &mut TestServer) {
for reason in ["Counsel's letter", "Counsel widened the matter", "Matter settled"] { for reason in ["Counsel's letter", "Counsel widened the matter", "Matter settled"] {
assert!(reasons.contains(&reason), "AU-12: {reason:?} not recorded: {reasons:?}"); assert!(reasons.contains(&reason), "AU-12: {reason:?} not recorded: {reasons:?}");
} }
// AU-1.9: an export is recorded with its reason
let (_, query) = admin
.hold_call(
"inbuxa:AuditEvent/query",
json!({"filter": {"targetKind": "inbuxa:HoldExport"}}),
)
.await;
let (_, exports) = admin
.hold_call("inbuxa:AuditEvent/get", json!({"ids": query["ids"].clone()}))
.await;
assert!(
exports["list"]
.as_array()
.is_some_and(|l| l.iter().any(|r| r["reason"] == "Production to opposing counsel")),
"AU-1.9: the export isn't recorded: {exports}"
);
// A release is recorded under the hold's name, from before to after // A release is recorded under the hold's name, from before to after
let list = records["list"].as_array().cloned().unwrap_or_default(); let list = records["list"].as_array().cloned().unwrap_or_default();
let release = list let release = list