Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de275bac60 | ||
|
|
305406a331 | ||
|
|
f5888d79b0 | ||
|
|
8e9cedbe97 | ||
|
|
5ba54e8fb7 | ||
|
|
db817dd507 | ||
|
|
b7e3a765ca | ||
|
|
7ba9ec9fa0 | ||
|
|
4c07779c16 | ||
|
|
e1e8a9aeb0 | ||
|
|
5c506b9d2b | ||
|
|
3978cf5785 | ||
|
|
815a642cc4 | ||
|
|
1d5f4a2cd3 | ||
|
|
68dd749291 | ||
|
|
b1bc5ed6e0 |
Generated
+2
@@ -4258,6 +4258,7 @@ dependencies = [
|
||||
"tungstenite 0.30.0",
|
||||
"types",
|
||||
"utils",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8624,6 +8625,7 @@ dependencies = [
|
||||
"types",
|
||||
"utils",
|
||||
"x509-parser",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -46,10 +46,10 @@ pub struct Network {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct NetworkInfo {
|
||||
pub pacc: Pacc,
|
||||
/// inbuxa: the same document without IMAP, POP3, SMTP and ManageSieve,
|
||||
/// served while legacy protocols are off (legacy-protocols LP-7).
|
||||
pub pacc_jmap_only: Pacc,
|
||||
/// inbuxa: the document once per combination of legacy protocols off,
|
||||
/// indexed by `LegacyOff::index` (legacy-protocols LP-7, one switch per
|
||||
/// protocol); index 0 is the full document.
|
||||
pub pacc: Vec<Pacc>,
|
||||
pub mxs: Vec<MailExchanger>,
|
||||
pub services: VecMap<ServiceProtocol, Service>,
|
||||
}
|
||||
@@ -333,16 +333,27 @@ impl Network {
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
// inbuxa: legacy-protocols LP-7
|
||||
let pacc_jmap_only = {
|
||||
let mut pacc = pacc.clone();
|
||||
pacc.protocols.imap = None;
|
||||
pacc.protocols.pop3 = None;
|
||||
pacc.protocols.smtp = None;
|
||||
pacc.protocols.managesieve = None;
|
||||
split(&pacc)
|
||||
};
|
||||
let pacc = split(&pacc);
|
||||
// inbuxa: legacy-protocols LP-7, one document per combination of
|
||||
// protocols off, bits as `LegacyOff::index`: IMAP, POP3, ManageSieve,
|
||||
// submission.
|
||||
let pacc = (0..16usize)
|
||||
.map(|off| {
|
||||
let mut pacc = pacc.clone();
|
||||
if off & 1 != 0 {
|
||||
pacc.protocols.imap = None;
|
||||
}
|
||||
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 {
|
||||
node_id: bp.node_id() as u64,
|
||||
server_name: default_hostname.to_string(),
|
||||
@@ -358,7 +369,6 @@ impl Network {
|
||||
mxs: system.mail_exchangers.into_iter().collect(),
|
||||
services: system.services,
|
||||
pacc,
|
||||
pacc_jmap_only,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 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::XmlVersion;
|
||||
use quick_xml::events::Event;
|
||||
@@ -59,11 +59,11 @@ impl Server {
|
||||
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
|
||||
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||
let legacy_off = match emailaddress.rsplit_once('@') {
|
||||
Some((_, domain)) => self.legacy_protocols_off_for(domain).await?,
|
||||
None => self.legacy_protocols_off_for("").await?,
|
||||
Some((_, domain)) => self.legacy_off_for(domain).await?,
|
||||
None => self.legacy_off_for("").await?,
|
||||
};
|
||||
for (protocol, service) in &self.core.network.info.services {
|
||||
if legacy_off && is_legacy_service(protocol) {
|
||||
if legacy_off.service(protocol) {
|
||||
continue;
|
||||
}
|
||||
let (protocol, ports) = match protocol {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 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 std::fmt::Write;
|
||||
use utils::url_params::UrlParams;
|
||||
@@ -31,7 +31,7 @@ impl Server {
|
||||
};
|
||||
|
||||
// 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
|
||||
let mut config = String::with_capacity(1024);
|
||||
@@ -45,7 +45,7 @@ impl Server {
|
||||
"\t\t<displayShortName>{domain}</displayShortName>"
|
||||
);
|
||||
for (protocol, service) in &self.core.network.info.services {
|
||||
if legacy_off && is_legacy_service(protocol) {
|
||||
if legacy_off.service(protocol) {
|
||||
continue;
|
||||
}
|
||||
let (protocol, tag, ports) = match protocol {
|
||||
|
||||
@@ -6,11 +6,7 @@
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
Server,
|
||||
config::network::Pacc,
|
||||
network::{dkim::generate_dkim_dns_record, legacy::is_legacy_service},
|
||||
};
|
||||
use crate::{Server, config::network::Pacc, network::dkim::generate_dkim_dns_record};
|
||||
use ahash::{AHashMap, AHashSet};
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use dns_update::{
|
||||
@@ -41,7 +37,7 @@ impl Server {
|
||||
let default_host = network.server_name.as_str();
|
||||
let domain_name = domain.name.as_str();
|
||||
// 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}");
|
||||
|
||||
for record_type in record_types {
|
||||
@@ -205,7 +201,7 @@ impl Server {
|
||||
// name says "not offered" -- target "." (RFC 6186 section
|
||||
// 3.4) -- rather than vanishing, so a client that looks
|
||||
// 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 {
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("_{service_name}._tcp.{domain_name}."),
|
||||
@@ -307,8 +303,8 @@ impl Server {
|
||||
// inbuxa: legacy-protocols LP-7. No TLS pin for a port
|
||||
// the switch has closed. Submission's port stays open
|
||||
// (the SMTP lock), so its record stays.
|
||||
if legacy_off
|
||||
&& matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3)
|
||||
if matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3)
|
||||
&& legacy_off.service(protocol)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -418,11 +414,8 @@ impl Server {
|
||||
|
||||
pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> {
|
||||
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||
let pacc = if self.legacy_protocols_off_for(domain_name).await? {
|
||||
&self.core.network.info.pacc_jmap_only
|
||||
} else {
|
||||
&self.core.network.info.pacc
|
||||
};
|
||||
let off = self.legacy_off_for(domain_name).await?;
|
||||
let pacc = &self.core.network.info.pacc[off.index()];
|
||||
self.get_directory_for_domain(domain_name)
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
|
||||
@@ -35,8 +35,8 @@ use directory::Credentials;
|
||||
use inbuxa_features::security::{
|
||||
legacy_use::{self, LegacyUse},
|
||||
listeners,
|
||||
protocol_policy::{self, ProtocolPolicy, SavedListener},
|
||||
tenant_protocol_policy,
|
||||
protocol_policy::{self, ProtocolPolicy, SUBMISSION, SWITCHED, SavedListener, Switches},
|
||||
tenant_protocol_policy::{self, OffBy, TenantProtocolPolicy},
|
||||
};
|
||||
use registry::schema::enums::ServiceProtocol;
|
||||
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
|
||||
// waiting to come back.
|
||||
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_by = changed_by;
|
||||
policy.normalize();
|
||||
|
||||
if policy.legacy_protocols.is_disabled() {
|
||||
self.close_legacy_listeners(&mut policy, &mut change).await?;
|
||||
} else {
|
||||
self.reopen_legacy_listeners(&mut policy, &mut change)
|
||||
.await?;
|
||||
}
|
||||
// Each protocol on its own switch: close what is off now, and put
|
||||
// back what was saved for a protocol that is on again. Either may
|
||||
// happen in one change, when one protocol goes off as another comes
|
||||
// back.
|
||||
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?;
|
||||
|
||||
// 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
|
||||
// not a change.
|
||||
if previous.legacy_protocols != policy.legacy_protocols || !change.is_empty() {
|
||||
let (moved, direction) = if policy.legacy_protocols.is_disabled() {
|
||||
(&change.closed, "closed")
|
||||
} else {
|
||||
(&change.reopened, "reopened")
|
||||
};
|
||||
let mut before = previous;
|
||||
before.normalize();
|
||||
if before.off() != policy.off() || !change.is_empty() {
|
||||
// The closed first, then the reopened; `Details` says which.
|
||||
let moved = change
|
||||
.closed
|
||||
.iter()
|
||||
.chain(change.reopened.iter())
|
||||
.map(|l| l.id.clone());
|
||||
trc::event!(
|
||||
Security(trc::SecurityEvent::LegacyProtocolsChanged),
|
||||
Policy = "server",
|
||||
Value = if policy.legacy_protocols.is_disabled() {
|
||||
"disabled"
|
||||
} else {
|
||||
"enabled"
|
||||
},
|
||||
Value = switches_value(&policy),
|
||||
AccountId = policy.changed_by.clone(),
|
||||
Details = direction,
|
||||
ListenerId = listener_names(moved.iter().map(|l| l.id.clone())),
|
||||
Details = if change.closed.is_empty() {
|
||||
"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).
|
||||
Reason = (!change.failed.is_empty()).then(|| listener_names(
|
||||
change
|
||||
@@ -165,21 +173,27 @@ impl Server {
|
||||
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(
|
||||
&self,
|
||||
policy: &mut ProtocolPolicy,
|
||||
change: &mut PolicyChange,
|
||||
) -> 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(());
|
||||
}
|
||||
|
||||
let saved = std::mem::take(&mut policy.saved_listeners);
|
||||
let (restored, failed) = listeners::reopen(self.registry(), &saved).await?;
|
||||
let (restored, failed) = listeners::reopen(self.registry(), &wanted).await?;
|
||||
|
||||
// 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;
|
||||
|
||||
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
|
||||
/// failed to reopen (LP-8).
|
||||
fn listener_names<T: Into<trc::Value>>(names: impl Iterator<Item = T>) -> trc::Value {
|
||||
@@ -395,15 +420,18 @@ impl Server {
|
||||
credentials: &Credentials,
|
||||
) -> trc::Result<()> {
|
||||
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));
|
||||
}
|
||||
if let Some(name) = &domain
|
||||
&& let Some(domain) = self.domain(name).await?
|
||||
&& 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(())
|
||||
}
|
||||
@@ -422,10 +450,16 @@ impl Server {
|
||||
protocol: LegacyProtocol,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<()> {
|
||||
if let Some(tenant_id) = access_token.tenant_id()
|
||||
&& self.tenant_legacy_protocols_off(tenant_id).await?
|
||||
{
|
||||
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), None));
|
||||
if let Some(tenant_id) = access_token.tenant_id() {
|
||||
let server = self.protocol_policy().await?;
|
||||
let tenant = self.tenant_protocol_policy(tenant_id).await?;
|
||||
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(
|
||||
&self.core.storage.data,
|
||||
@@ -463,31 +497,96 @@ impl Server {
|
||||
Ok(recent)
|
||||
}
|
||||
|
||||
/// Whether legacy protocols are off for this account: the stricter of the
|
||||
/// server's switch and its tenant's. What the JMAP session tells the
|
||||
/// Which legacy protocols are off for this account: each the stricter of
|
||||
/// 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
|
||||
/// 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,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<bool> {
|
||||
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
|
||||
return Ok(true);
|
||||
}
|
||||
match access_token.tenant_id() {
|
||||
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await,
|
||||
None => Ok(false),
|
||||
) -> trc::Result<LegacyOff> {
|
||||
let server = self.protocol_policy().await?;
|
||||
let tenant = match access_token.tenant_id() {
|
||||
Some(tenant_id) => Some(self.tenant_protocol_policy(tenant_id).await?),
|
||||
None => None,
|
||||
};
|
||||
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).
|
||||
pub async fn tenant_legacy_protocols_off(&self, tenant_id: u32) -> trc::Result<bool> {
|
||||
Ok(
|
||||
tenant_protocol_policy::get(&self.core.storage.data, tenant_id)
|
||||
.await?
|
||||
.legacy_protocols
|
||||
.is_disabled(),
|
||||
)
|
||||
/// Whether this configured service must not be offered (LP-7). SMTP here
|
||||
/// is submission; inbound mail is never a configured service.
|
||||
pub fn service(&self, protocol: &ServiceProtocol) -> bool {
|
||||
match protocol {
|
||||
ServiceProtocol::Imap => self.imap,
|
||||
ServiceProtocol::Pop3 => self.pop3,
|
||||
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 {
|
||||
/// 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
|
||||
/// 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.
|
||||
pub async fn legacy_protocols_off_for(&self, domain_name: &str) -> trc::Result<bool> {
|
||||
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
|
||||
return Ok(true);
|
||||
}
|
||||
match self.domain(domain_name).await? {
|
||||
pub async fn legacy_off_for(&self, domain_name: &str) -> trc::Result<LegacyOff> {
|
||||
let server = self.protocol_policy().await?;
|
||||
let tenant = match self.domain(domain_name).await? {
|
||||
Some(domain) => match domain.id_tenant {
|
||||
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await,
|
||||
None => Ok(false),
|
||||
Some(tenant_id) => Some(self.tenant_protocol_policy(tenant_id).await?),
|
||||
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]
|
||||
fn the_domain_comes_from_the_name_given() {
|
||||
assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string()));
|
||||
|
||||
@@ -108,6 +108,45 @@ impl Keeping {
|
||||
const FEATURE: u8 = b'H';
|
||||
const KIND_HOLD: u8 = b'h';
|
||||
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.
|
||||
const CREATE_ATTEMPTS: usize = 5;
|
||||
@@ -402,6 +441,70 @@ pub async fn set_original_deadline(data: &Store, item_id: u64, until: Option<u64
|
||||
.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.
|
||||
pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Hold>> {
|
||||
Ok(data
|
||||
|
||||
@@ -8,6 +8,17 @@
|
||||
//! (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.
|
||||
//!
|
||||
//! 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
|
||||
//! which listeners were taken away to honour it. Closing sockets belongs to
|
||||
//! `common`, which owns the listener registry, and removing the listener
|
||||
@@ -59,12 +70,30 @@ pub struct SavedListener {
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
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,
|
||||
/// 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
|
||||
/// listener on port 25 is never closed, whatever this says.
|
||||
pub close_submission: bool,
|
||||
@@ -80,6 +109,9 @@ impl Default for ProtocolPolicy {
|
||||
fn default() -> Self {
|
||||
ProtocolPolicy {
|
||||
legacy_protocols: LegacyProtocols::Enabled,
|
||||
imap: None,
|
||||
pop3: None,
|
||||
manage_sieve: None,
|
||||
close_submission: true,
|
||||
saved_listeners: Vec::new(),
|
||||
changed_at: None,
|
||||
@@ -91,6 +123,9 @@ impl Default for ProtocolPolicy {
|
||||
/// The properties `inbuxa:ProtocolPolicy` has, as they appear over JMAP.
|
||||
pub const PROPERTIES: &[&str] = &[
|
||||
"legacyProtocols",
|
||||
"imap",
|
||||
"pop3",
|
||||
"manageSieve",
|
||||
"closeSubmission",
|
||||
"savedListeners",
|
||||
"changedAt",
|
||||
@@ -129,23 +164,137 @@ pub fn is_locked(protocol: &str) -> bool {
|
||||
.any(|locked| locked.eq_ignore_ascii_case(protocol))
|
||||
}
|
||||
|
||||
impl ProtocolPolicy {
|
||||
/// Whether a listener of this protocol and these ports is one the switch
|
||||
/// closes. A listener bound to port 25 is inbound whatever its name, and
|
||||
/// any other SMTP listener counts as submission (LP-3).
|
||||
pub fn closes(&self, protocol: &str, ports: &[u16]) -> bool {
|
||||
if !self.legacy_protocols.is_disabled() {
|
||||
return false;
|
||||
/// The switch fields, by protocol name.
|
||||
pub trait Switches {
|
||||
/// The kill-all, which an unset per-protocol switch reads as.
|
||||
fn all(&self) -> LegacyProtocols;
|
||||
fn slot(&self, protocol: &str) -> Option<&Option<LegacyProtocols>>;
|
||||
fn slot_mut(&mut self, protocol: &str) -> Option<&mut Option<LegacyProtocols>>;
|
||||
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
|
||||
// request phrasing can reach past it (LP-21).
|
||||
if is_locked(protocol) {
|
||||
return false;
|
||||
}
|
||||
if LEGACY_PROTOCOLS.contains(&protocol) {
|
||||
return true;
|
||||
return self.is_off(protocol);
|
||||
}
|
||||
protocol.eq_ignore_ascii_case("smtp")
|
||||
&& self.all_off()
|
||||
&& self.close_submission
|
||||
&& !ports.contains(&INBOUND_SMTP_PORT)
|
||||
}
|
||||
@@ -258,7 +407,10 @@ mod tests {
|
||||
"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 {
|
||||
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.
|
||||
#[test]
|
||||
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
|
||||
//! 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
|
||||
//! 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
|
||||
//! 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 store::{
|
||||
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
|
||||
@@ -26,24 +32,92 @@ use trc::AddContext;
|
||||
#[derive(Debug, Clone, PartialEq, Default, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct TenantProtocolPolicy {
|
||||
/// The switch itself.
|
||||
/// The kill-all, as on the server's policy.
|
||||
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.
|
||||
pub changed_at: Option<u64>,
|
||||
/// The account that last changed it.
|
||||
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
|
||||
/// them back on only while the server has them on: server off means off for
|
||||
/// everyone.
|
||||
pub fn refusal(server: &ProtocolPolicy, requested: LegacyProtocols) -> Option<&'static str> {
|
||||
(server.legacy_protocols.is_disabled() && !requested.is_disabled()).then_some(
|
||||
"Legacy mail protocols are off for the whole server (inbuxa:ProtocolPolicy), \
|
||||
so they can't be turned back on for one organization.",
|
||||
)
|
||||
/// A tenant can always turn a protocol off for itself. It can turn one on
|
||||
/// only while the server has it on: server off means off for everyone.
|
||||
/// `turned_on` is what the request sets to `enabled`, by protocol name.
|
||||
pub fn refusal(server: &ProtocolPolicy, turned_on: &[&str]) -> Option<String> {
|
||||
let blocked: Vec<&str> = turned_on
|
||||
.iter()
|
||||
.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 {
|
||||
@@ -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]
|
||||
fn a_tenant_starts_with_legacy_protocols_on() {
|
||||
assert!(
|
||||
@@ -127,20 +210,59 @@ mod tests {
|
||||
#[test]
|
||||
fn a_tenant_can_always_turn_them_off() {
|
||||
for s in [LegacyProtocols::Enabled, LegacyProtocols::Disabled] {
|
||||
assert_eq!(refusal(&server(s), LegacyProtocols::Disabled), None);
|
||||
assert_eq!(refusal(&server(s), &[]), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tenant_can_turn_them_on_only_while_the_server_has_them_on() {
|
||||
// LP-9, acceptance test 9.
|
||||
assert_eq!(
|
||||
refusal(&server(LegacyProtocols::Enabled), LegacyProtocols::Enabled),
|
||||
None
|
||||
);
|
||||
let why =
|
||||
refusal(&server(LegacyProtocols::Disabled), LegacyProtocols::Enabled).expect("refused");
|
||||
assert_eq!(refusal(&server(LegacyProtocols::Enabled), SWITCHED), None);
|
||||
let why = refusal(&server(LegacyProtocols::Disabled), SWITCHED).expect("refused");
|
||||
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]
|
||||
@@ -158,6 +280,7 @@ mod tests {
|
||||
legacy_protocols: LegacyProtocols::Disabled,
|
||||
changed_at: Some(1),
|
||||
changed_by: Some("b".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let Json(back) = Json::deserialize(&serde_json::to_vec(&policy).unwrap()).unwrap();
|
||||
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)]
|
||||
pub enum ProtocolPolicyProperty {
|
||||
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,
|
||||
/// Each protocol's own switch: `enabled` or `disabled`.
|
||||
Imap,
|
||||
Pop3,
|
||||
ManageSieve,
|
||||
/// Whether submission closes with it. Forced false while SMTP is locked.
|
||||
CloseSubmission,
|
||||
/// Server-set: the listeners taken away, for LP-5.
|
||||
@@ -56,6 +61,9 @@ impl Property for ProtocolPolicyProperty {
|
||||
match self {
|
||||
ProtocolPolicyProperty::Id => "id",
|
||||
ProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
|
||||
ProtocolPolicyProperty::Imap => "imap",
|
||||
ProtocolPolicyProperty::Pop3 => "pop3",
|
||||
ProtocolPolicyProperty::ManageSieve => "manageSieve",
|
||||
ProtocolPolicyProperty::CloseSubmission => "closeSubmission",
|
||||
ProtocolPolicyProperty::SavedListeners => "savedListeners",
|
||||
ProtocolPolicyProperty::ChangedAt => "changedAt",
|
||||
@@ -73,6 +81,9 @@ impl ProtocolPolicyProperty {
|
||||
hashify::tiny_map!(value.as_bytes(),
|
||||
b"id" => ProtocolPolicyProperty::Id,
|
||||
b"legacyProtocols" => ProtocolPolicyProperty::LegacyProtocols,
|
||||
b"imap" => ProtocolPolicyProperty::Imap,
|
||||
b"pop3" => ProtocolPolicyProperty::Pop3,
|
||||
b"manageSieve" => ProtocolPolicyProperty::ManageSieve,
|
||||
b"closeSubmission" => ProtocolPolicyProperty::CloseSubmission,
|
||||
b"savedListeners" => ProtocolPolicyProperty::SavedListeners,
|
||||
b"changedAt" => ProtocolPolicyProperty::ChangedAt,
|
||||
|
||||
@@ -24,8 +24,13 @@ pub enum TenantProtocolPolicyProperty {
|
||||
Id,
|
||||
/// Server-set: the tenant this is the switch of.
|
||||
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,
|
||||
/// Each protocol's own switch: `enabled` or `disabled`.
|
||||
Imap,
|
||||
Pop3,
|
||||
ManageSieve,
|
||||
ChangedAt,
|
||||
ChangedBy,
|
||||
/// 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::TenantId => "tenantId",
|
||||
TenantProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
|
||||
TenantProtocolPolicyProperty::Imap => "imap",
|
||||
TenantProtocolPolicyProperty::Pop3 => "pop3",
|
||||
TenantProtocolPolicyProperty::ManageSieve => "manageSieve",
|
||||
TenantProtocolPolicyProperty::ChangedAt => "changedAt",
|
||||
TenantProtocolPolicyProperty::ChangedBy => "changedBy",
|
||||
TenantProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse",
|
||||
@@ -62,6 +70,9 @@ impl TenantProtocolPolicyProperty {
|
||||
b"id" => TenantProtocolPolicyProperty::Id,
|
||||
b"tenantId" => TenantProtocolPolicyProperty::TenantId,
|
||||
b"legacyProtocols" => TenantProtocolPolicyProperty::LegacyProtocols,
|
||||
b"imap" => TenantProtocolPolicyProperty::Imap,
|
||||
b"pop3" => TenantProtocolPolicyProperty::Pop3,
|
||||
b"manageSieve" => TenantProtocolPolicyProperty::ManageSieve,
|
||||
b"changedAt" => TenantProtocolPolicyProperty::ChangedAt,
|
||||
b"changedBy" => TenantProtocolPolicyProperty::ChangedBy,
|
||||
b"recentLegacyUse" => TenantProtocolPolicyProperty::RecentLegacyUse,
|
||||
|
||||
@@ -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_audit; // inbuxa: the audit log
|
||||
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_protocol_policy; // inbuxa: legacy protocols off
|
||||
pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant
|
||||
|
||||
@@ -73,6 +73,9 @@ impl Response<'_> {
|
||||
GetResponseMethod::LegalHold(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::HoldExport(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::ProtocolPolicy(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ impl Response<'_> {
|
||||
GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::AccountLock(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::TenantProtocolPolicy(request) => {
|
||||
request.resolve_references(self)?
|
||||
@@ -115,6 +116,9 @@ impl Response<'_> {
|
||||
SetRequestMethod::LegalHold(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::HoldExport(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::ProtocolPolicy(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
|
||||
@@ -169,6 +169,11 @@ pub struct InbuxaAccountCapabilities {
|
||||
/// (legacy-protocols spec, Interfaces; LP-19).
|
||||
#[serde(rename(serialize = "legacyProtocols"))]
|
||||
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
|
||||
/// `sysAiExplain`, is server-level, and a model resolves (ai-explain
|
||||
/// spec, EX-1 to EX-4).
|
||||
|
||||
@@ -60,6 +60,7 @@ pub enum MethodObject {
|
||||
AccountLock,
|
||||
// inbuxa: legal hold
|
||||
LegalHold,
|
||||
HoldExport,
|
||||
ProtocolPolicy,
|
||||
TenantProtocolPolicy,
|
||||
}
|
||||
@@ -94,7 +95,8 @@ impl MethodObject {
|
||||
| MethodObject::AuditExport
|
||||
| MethodObject::AuditVerification
|
||||
| MethodObject::AccountLock
|
||||
| MethodObject::LegalHold => Capability::Inbuxa,
|
||||
| MethodObject::LegalHold
|
||||
| MethodObject::HoldExport => Capability::Inbuxa,
|
||||
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
||||
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
||||
}
|
||||
@@ -284,6 +286,8 @@ impl MethodName {
|
||||
(MethodFunction::Set, MethodObject::AccountLock) => "inbuxa:AccountLock/set",
|
||||
(MethodFunction::Get, MethodObject::LegalHold) => "inbuxa:LegalHold/get",
|
||||
(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) => {
|
||||
"inbuxa:AuditVerification/set"
|
||||
}
|
||||
@@ -430,6 +434,8 @@ impl MethodName {
|
||||
"inbuxa:AccountLock/set" => (MethodObject::AccountLock, MethodFunction::Set),
|
||||
"inbuxa:LegalHold/get" => (MethodObject::LegalHold, MethodFunction::Get),
|
||||
"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:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get),
|
||||
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
|
||||
@@ -495,6 +501,7 @@ impl Display for MethodObject {
|
||||
MethodObject::AuditVerification => "inbuxa:AuditVerification",
|
||||
MethodObject::AccountLock => "inbuxa:AccountLock",
|
||||
MethodObject::LegalHold => "inbuxa:LegalHold",
|
||||
MethodObject::HoldExport => "inbuxa:HoldExport",
|
||||
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
||||
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
||||
MethodObject::Registry(obj) => {
|
||||
|
||||
@@ -120,6 +120,7 @@ pub enum GetRequestMethod {
|
||||
AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>),
|
||||
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||
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>>),
|
||||
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>>),
|
||||
AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>),
|
||||
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>>),
|
||||
TenantProtocolPolicy(
|
||||
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||
|
||||
@@ -566,6 +566,21 @@ impl<'de> Visitor<'de> for CallVisitor {
|
||||
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
|
||||
(MethodFunction::Get, MethodObject::LegalHold) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LegalHold(value)),
|
||||
|
||||
@@ -107,6 +107,7 @@ pub enum GetResponseMethod {
|
||||
AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>),
|
||||
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
|
||||
LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>),
|
||||
HoldExport(GetResponse<crate::object::inbuxa_hold_export::HoldExport>),
|
||||
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
|
||||
TenantProtocolPolicy(
|
||||
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||
@@ -140,6 +141,7 @@ pub enum SetResponseMethod {
|
||||
AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>),
|
||||
AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||
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>>),
|
||||
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
TenantProtocolPolicy(
|
||||
@@ -780,3 +782,16 @@ impl<'x> From<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>> for Resp
|
||||
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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ base64 = "0.23"
|
||||
p256 = { version = "0.13", features = ["ecdh"] }
|
||||
sha1 = "0.11"
|
||||
sha2 = "0.11"
|
||||
zip = "8.6" # inbuxa: legal hold exports (LH-12)
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"]}
|
||||
tokio-tungstenite = "0.30"
|
||||
tungstenite = "0.30"
|
||||
|
||||
@@ -97,6 +97,7 @@ impl JmapAuthorization for AccessToken {
|
||||
// inbuxa: account lock (AL-12)
|
||||
GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet,
|
||||
GetRequestMethod::LegalHold(_) => Permission::SysLegalHoldGet,
|
||||
GetRequestMethod::HoldExport(_) => Permission::SysLegalHoldExport,
|
||||
// inbuxa: legacy protocols off. It takes listeners away and
|
||||
// puts them back, so it takes the listener's permissions
|
||||
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
|
||||
@@ -232,6 +233,14 @@ impl JmapAuthorization for AccessToken {
|
||||
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(
|
||||
s,
|
||||
self,
|
||||
@@ -380,6 +389,7 @@ impl JmapAuthorization for AccessToken {
|
||||
| MethodObject::AuditVerification
|
||||
| MethodObject::AccountLock
|
||||
| MethodObject::LegalHold
|
||||
| MethodObject::HoldExport
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
|
||||
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
||||
|
||||
@@ -276,6 +276,9 @@ impl RequestHandler for Server {
|
||||
SetResponseMethod::LegalHold(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::HoldExport(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::Explanation(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
@@ -450,6 +453,11 @@ impl RequestHandler for Server {
|
||||
.into()
|
||||
}
|
||||
// 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) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
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
|
||||
// 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) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
let reason = req.arguments.reason.clone().or_else(|| {
|
||||
|
||||
@@ -66,12 +66,16 @@ impl SessionHandler for Server {
|
||||
Capability::Inbuxa,
|
||||
Capabilities::Empty(EmptyCapabilities::default()),
|
||||
);
|
||||
// inbuxa: legacy-protocols, Interfaces: whichever switch is stricter
|
||||
let legacy_protocols = if self.legacy_protocols_off_for_account(access_token).await? {
|
||||
// inbuxa: legacy-protocols, Interfaces: whichever switch is stricter,
|
||||
// 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"
|
||||
} else {
|
||||
"enabled"
|
||||
};
|
||||
let legacy_allowed = legacy_off.allowed();
|
||||
// inbuxa: ai-explain, EX-1 to EX-4: whether Explain can be offered
|
||||
let ai_explain = access_token.has_permission(Permission::SysAiExplain)
|
||||
&& access_token.tenant_id().is_none()
|
||||
@@ -81,6 +85,7 @@ impl SessionHandler for Server {
|
||||
Capabilities::Inbuxa(InbuxaAccountCapabilities {
|
||||
logo,
|
||||
legacy_protocols,
|
||||
legacy_allowed,
|
||||
ai_explain,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -425,6 +425,7 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::AuditVerification
|
||||
| MethodObject::AccountLock
|
||||
| MethodObject::LegalHold
|
||||
| MethodObject::HoldExport
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::TenantProtocolPolicy
|
||||
| MethodObject::Registry(_) => unreachable!(),
|
||||
|
||||
@@ -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> {
|
||||
use inbuxa_features::{ai::limits, audit::log, security};
|
||||
use inbuxa_features::{
|
||||
ai::limits,
|
||||
audit::log,
|
||||
security::{self, protocol_policy::Switches},
|
||||
};
|
||||
let data = server.store();
|
||||
match object {
|
||||
"inbuxa:AuditSettings" => log::settings(data)
|
||||
@@ -387,10 +391,17 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> O
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|limits| serde_json::to_value(limits).ok()),
|
||||
"inbuxa:ProtocolPolicy" => security::protocol_policy::get(data)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|policy| serde_json::to_value(policy).ok()),
|
||||
// Normalized, so every switch reads before and after, even from a
|
||||
// policy stored before the per-protocol switches
|
||||
"inbuxa:ProtocolPolicy" => {
|
||||
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
|
||||
"inbuxa:LegalHold" => match 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())
|
||||
.await
|
||||
.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,
|
||||
},
|
||||
|
||||
@@ -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())))));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
pub mod access;
|
||||
pub mod account_lock;
|
||||
pub mod legal_hold;
|
||||
pub mod hold_export;
|
||||
pub mod hold_export_api;
|
||||
pub mod audit;
|
||||
pub mod audit_log;
|
||||
pub mod ai_limits;
|
||||
|
||||
@@ -26,7 +26,9 @@ use common::{
|
||||
};
|
||||
use inbuxa_features::security::{
|
||||
listeners,
|
||||
protocol_policy::{LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener},
|
||||
protocol_policy::{
|
||||
LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener, Switches,
|
||||
},
|
||||
};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
@@ -48,6 +50,9 @@ type PValue = Value<'static, P, ProtocolPolicyValue>;
|
||||
const ALL: &[P] = &[
|
||||
P::Id,
|
||||
P::LegacyProtocols,
|
||||
P::Imap,
|
||||
P::Pop3,
|
||||
P::ManageSieve,
|
||||
P::CloseSubmission,
|
||||
P::SavedListeners,
|
||||
P::ChangedAt,
|
||||
@@ -91,22 +96,49 @@ fn listener_value(listener: &SavedListener) -> PValue {
|
||||
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(
|
||||
policy: &Policy,
|
||||
would_close: &[SavedListener],
|
||||
recent: &[RecentUse],
|
||||
properties: &[P],
|
||||
) -> PValue {
|
||||
let mut policy = policy.clone();
|
||||
policy.normalize();
|
||||
let policy = &policy;
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
P::Id => Value::Element(ProtocolPolicyValue::Id(Id::singleton())),
|
||||
P::LegacyProtocols => Value::Str(
|
||||
match policy.legacy_protocols {
|
||||
LegacyProtocols::Enabled => "enabled",
|
||||
LegacyProtocols::Disabled => "disabled",
|
||||
}
|
||||
.into(),
|
||||
P::LegacyProtocols => Value::Str(switch_str(policy.legacy_protocols).into()),
|
||||
P::Imap | P::Pop3 | P::ManageSieve => Value::Str(
|
||||
switch_str(policy.switch(switch_name(property).unwrap_or_default())).into(),
|
||||
),
|
||||
P::CloseSubmission => Value::Bool(policy.close_submission),
|
||||
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>> {
|
||||
let mut hypothetical = policy.clone();
|
||||
hypothetical.legacy_protocols = LegacyProtocols::Disabled;
|
||||
hypothetical.set_all(LegacyProtocols::Disabled);
|
||||
hypothetical.apply_locks();
|
||||
listeners::would_close(server.registry(), &hypothetical).await
|
||||
}
|
||||
@@ -238,12 +271,12 @@ fn apply(
|
||||
value: &Value<'_, P, ProtocolPolicyValue>,
|
||||
) -> Result<(), String> {
|
||||
match property {
|
||||
P::LegacyProtocols => {
|
||||
policy.legacy_protocols = match value.as_str().as_deref() {
|
||||
Some("enabled") => LegacyProtocols::Enabled,
|
||||
Some("disabled") => LegacyProtocols::Disabled,
|
||||
_ => return Err(r#"must be "enabled" or "disabled""#.to_string()),
|
||||
}
|
||||
// The kill-all sets all three; a protocol named in the same /set is
|
||||
// applied after it (see `set`), so it wins.
|
||||
P::LegacyProtocols => policy.set_all(parse_switch(value.as_str().as_deref())?),
|
||||
P::Imap | P::Pop3 | P::ManageSieve => {
|
||||
let value = parse_switch(value.as_str().as_deref())?;
|
||||
policy.set(switch_name(property).unwrap_or_default(), value);
|
||||
}
|
||||
P::CloseSubmission => {
|
||||
policy.close_submission = value
|
||||
@@ -262,7 +295,13 @@ fn apply(
|
||||
/// Puts a property back to its default (a `null` in `/set`).
|
||||
fn reset(policy: &mut Policy, property: &P, defaults: &Policy) -> Result<(), String> {
|
||||
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::Id => return Err("is immutable".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?;
|
||||
policy.normalize();
|
||||
let defaults = Policy::default();
|
||||
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 {
|
||||
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
|
||||
break;
|
||||
@@ -393,21 +436,30 @@ fn listener_refusal(policy: &Policy, listener: &NetworkListener) -> Option<(Prop
|
||||
let protocol = listeners::protocol_name(listener.protocol);
|
||||
// A submission listener closes because of its port, not its protocol
|
||||
// (LP-3), so the port is what would have to change.
|
||||
let property = if protocol == "smtp" {
|
||||
Property::Bind
|
||||
let (property, what) = if protocol == "smtp" {
|
||||
(Property::Bind, "Legacy mail protocols are".to_string())
|
||||
} else {
|
||||
Property::Protocol
|
||||
(Property::Protocol, format!("{} is", display_name(protocol)))
|
||||
};
|
||||
Some((
|
||||
property,
|
||||
format!(
|
||||
"Legacy mail protocols are off (inbuxa:ProtocolPolicy), and this {protocol} \
|
||||
listener would reopen a port the switch keeps closed. Turn legacy protocols \
|
||||
back on first."
|
||||
"{what} off (inbuxa:ProtocolPolicy), and this {protocol} listener would reopen \
|
||||
a port the switch keeps closed. Turn it 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)]
|
||||
mod tests {
|
||||
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]
|
||||
fn off_still_allows_what_the_switch_never_closes() {
|
||||
// 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
|
||||
//! level, `/get` with no ids answers with every tenant's.
|
||||
//!
|
||||
//! Turning it off never needs the server's leave; turning it back on is
|
||||
//! refused with `forbidden` while the server has legacy protocols off (LP-9).
|
||||
//! Each of IMAP, POP3 and ManageSieve has its own switch, and
|
||||
//! `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
|
||||
//! configuration read it (LP-10, LP-14a).
|
||||
|
||||
use crate::inbuxa::protocol_policy::recent_value;
|
||||
use common::{Server, auth::AccessToken, network::legacy::RecentUse};
|
||||
use crate::inbuxa::protocol_policy::{parse_switch, recent_value, switch_str};
|
||||
use common::{
|
||||
Server,
|
||||
auth::AccessToken,
|
||||
network::legacy::{RecentUse, switches_value},
|
||||
};
|
||||
use inbuxa_features::{
|
||||
security::{
|
||||
protocol_policy::LegacyProtocols,
|
||||
protocol_policy::{LegacyProtocols, SWITCHED, Switches},
|
||||
tenant_protocol_policy::{self, TenantProtocolPolicy as Policy, refusal},
|
||||
},
|
||||
tenancy::quota::all_tenants,
|
||||
@@ -46,6 +52,9 @@ const ALL: &[P] = &[
|
||||
P::Id,
|
||||
P::TenantId,
|
||||
P::LegacyProtocols,
|
||||
P::Imap,
|
||||
P::Pop3,
|
||||
P::ManageSieve,
|
||||
P::ChangedAt,
|
||||
P::ChangedBy,
|
||||
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 {
|
||||
let mut policy = policy.clone();
|
||||
policy.normalize();
|
||||
let policy = &policy;
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
P::Id | P::TenantId => {
|
||||
Value::Element(TenantProtocolPolicyValue::Id(Id::from(tenant_id)))
|
||||
}
|
||||
P::LegacyProtocols => Value::Str(
|
||||
match policy.legacy_protocols {
|
||||
LegacyProtocols::Enabled => "enabled",
|
||||
LegacyProtocols::Disabled => "disabled",
|
||||
}
|
||||
.into(),
|
||||
P::LegacyProtocols => Value::Str(switch_str(policy.legacy_protocols).into()),
|
||||
P::Imap | P::Pop3 | P::ManageSieve => Value::Str(
|
||||
switch_str(policy.switch(switch_name(property).unwrap_or_default())).into(),
|
||||
),
|
||||
P::ChangedAt => policy
|
||||
.changed_at
|
||||
@@ -165,29 +184,41 @@ pub async fn set(
|
||||
let data = &server.core.storage.data;
|
||||
let previous = tenant_protocol_policy::get(data, tenant_id).await?;
|
||||
let mut policy = previous.clone();
|
||||
policy.normalize();
|
||||
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 {
|
||||
Key::Property(P::LegacyProtocols) => match value {
|
||||
Value::Null => {
|
||||
policy.legacy_protocols = LegacyProtocols::Enabled;
|
||||
Ok(())
|
||||
Key::Property(P::LegacyProtocols) => parsed.map(|value| {
|
||||
policy.set_all(value);
|
||||
if !value.is_disabled() {
|
||||
turned_on.extend(SWITCHED.iter().copied());
|
||||
} else {
|
||||
turned_on.clear();
|
||||
}
|
||||
value => match value.as_str().as_deref() {
|
||||
Some("enabled") => {
|
||||
policy.legacy_protocols = LegacyProtocols::Enabled;
|
||||
Ok(())
|
||||
}),
|
||||
Key::Property(property @ (P::Imap | P::Pop3 | P::ManageSieve)) => {
|
||||
parsed.map(|value| {
|
||||
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(())
|
||||
}
|
||||
_ => Err(r#"must be "enabled" or "disabled""#),
|
||||
},
|
||||
},
|
||||
Key::Property(P::Id) => Err("is immutable"),
|
||||
Key::Property(_) => Err("is set by the server"),
|
||||
_ => Err("is not a property of inbuxa:TenantProtocolPolicy"),
|
||||
})
|
||||
}
|
||||
Key::Property(P::Id) => Err("is immutable".to_string()),
|
||||
Key::Property(_) => Err("is set by the server".to_string()),
|
||||
_ => Err("is not a property of inbuxa:TenantProtocolPolicy".to_string()),
|
||||
};
|
||||
if let Err(why) = result {
|
||||
error = Some(
|
||||
@@ -203,15 +234,18 @@ pub async fn set(
|
||||
continue;
|
||||
}
|
||||
|
||||
// LP-9: server off means off for everyone.
|
||||
if let Some(why) = refusal(&server.protocol_policy().await?, policy.legacy_protocols) {
|
||||
// LP-9: server off means off for everyone, protocol by protocol.
|
||||
if let Some(why) = refusal(&server.protocol_policy().await?, &turned_on) {
|
||||
response
|
||||
.not_updated
|
||||
.append(id, SetError::forbidden().with_description(why));
|
||||
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_by = Some(Id::from(access_token.account_id()).to_string());
|
||||
tenant_protocol_policy::set(data, tenant_id, &policy).await?;
|
||||
@@ -221,11 +255,7 @@ pub async fn set(
|
||||
Security(trc::SecurityEvent::LegacyProtocolsChanged),
|
||||
Policy = "tenant",
|
||||
Id = tenant_id,
|
||||
Value = if policy.legacy_protocols.is_disabled() {
|
||||
"disabled"
|
||||
} else {
|
||||
"enabled"
|
||||
},
|
||||
Value = switches_value(&policy),
|
||||
AccountId = policy.changed_by.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use common::{
|
||||
@@ -13,6 +15,8 @@ use mail_auth::{
|
||||
MX, RecordSet,
|
||||
common::resolver::ToFqdn,
|
||||
hickory_resolver::{
|
||||
TokioResolver,
|
||||
lookup::Lookup,
|
||||
net::{DnsError, NetError},
|
||||
proto::{
|
||||
dnssec::Proof,
|
||||
@@ -83,16 +87,15 @@ impl TlsaLookup for Server {
|
||||
return mail_auth::common::resolver::mock_resolve(key.as_ref());
|
||||
}
|
||||
|
||||
let mx_lookup = match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dnssec
|
||||
.resolver
|
||||
.mx_lookup(Name::from_str_relaxed::<&str>(key.as_ref())?)
|
||||
.await
|
||||
let (mx_lookup, forced_insecure) = match validated_lookup(
|
||||
&self.core.smtp.resolvers.dnssec.resolver,
|
||||
self.core.smtp.resolvers.dns.resolver(),
|
||||
Name::from_str_relaxed::<&str>(key.as_ref())?,
|
||||
RecordType::MX,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mx_lookup) => mx_lookup,
|
||||
Ok(validated) => (validated.lookup, validated.insecure),
|
||||
Err(err) => {
|
||||
if let Some(denial) = NegativeAnswer::from_error(&err)
|
||||
&& denial.response_code == ResponseCode::NoError
|
||||
@@ -144,7 +147,11 @@ impl TlsaLookup for Server {
|
||||
.collect::<Arc<[MX]>>();
|
||||
let records = RecordSet {
|
||||
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
|
||||
@@ -285,16 +292,15 @@ impl TlsaLookup for Server {
|
||||
}
|
||||
|
||||
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
|
||||
let lookup = match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dnssec
|
||||
.resolver
|
||||
.ipv4_lookup(name.clone())
|
||||
.await
|
||||
let (lookup, forced_insecure) = match validated_lookup(
|
||||
&self.core.smtp.resolvers.dnssec.resolver,
|
||||
self.core.smtp.resolvers.dns.resolver(),
|
||||
name.clone(),
|
||||
RecordType::A,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(lookup) => lookup,
|
||||
Ok(validated) => (validated.lookup, validated.insecure),
|
||||
Err(err) => {
|
||||
if let Some(denial) = NegativeAnswer::from_error(&err)
|
||||
&& denial.response_code == ResponseCode::NoError
|
||||
@@ -325,7 +331,11 @@ impl TlsaLookup for Server {
|
||||
_ => None,
|
||||
})
|
||||
.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
|
||||
@@ -363,16 +373,15 @@ impl TlsaLookup for Server {
|
||||
}
|
||||
|
||||
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
|
||||
let lookup = match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dnssec
|
||||
.resolver
|
||||
.ipv6_lookup(name.clone())
|
||||
.await
|
||||
let (lookup, forced_insecure) = match validated_lookup(
|
||||
&self.core.smtp.resolvers.dnssec.resolver,
|
||||
self.core.smtp.resolvers.dns.resolver(),
|
||||
name.clone(),
|
||||
RecordType::AAAA,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(lookup) => lookup,
|
||||
Ok(validated) => (validated.lookup, validated.insecure),
|
||||
Err(err) => {
|
||||
if let Some(denial) = NegativeAnswer::from_error(&err)
|
||||
&& denial.response_code == ResponseCode::NoError
|
||||
@@ -403,7 +412,11 @@ impl TlsaLookup for Server {
|
||||
_ => None,
|
||||
})
|
||||
.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
|
||||
@@ -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 {
|
||||
response_code: ResponseCode,
|
||||
dnssec_status: DnssecStatus,
|
||||
@@ -511,7 +633,7 @@ pub(crate) fn least_secure(a: DnssecStatus, b: DnssecStatus) -> DnssecStatus {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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;
|
||||
|
||||
fn name(value: &str) -> Name {
|
||||
@@ -624,4 +746,127 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ use mail_auth::{
|
||||
common::verify::VerifySignature,
|
||||
dkim2::Dkim2Output,
|
||||
dmarc::{self},
|
||||
report::{AuthFailureType, IdentityAlignment, PolicyPublished, Record, SPFDomainScope},
|
||||
report::{
|
||||
ActionDisposition, AuthFailureType, IdentityAlignment, PolicyPublished, Record, Report,
|
||||
SPFDomainScope,
|
||||
},
|
||||
};
|
||||
use registry::{
|
||||
schema::{
|
||||
@@ -459,7 +462,7 @@ impl DmarcReporting for Server {
|
||||
.await
|
||||
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_compact_string());
|
||||
let mut message = Vec::with_capacity(2048);
|
||||
let _ = mail_auth::report::Report::from(report.report).write_rfc5322(
|
||||
let _ = with_compatible_dispositions(Report::from(report.report)).write_rfc5322(
|
||||
&self
|
||||
.eval_if(
|
||||
&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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option<String> {
|
||||
#[macro_export]
|
||||
macro_rules! brand_version {
|
||||
() => {
|
||||
"2026.9.28"
|
||||
"2026.9.28.3"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
@@ -86,6 +86,7 @@ dns-update = { version = "0.5", features = ["test_provider"] }
|
||||
x509-parser = "0.18"
|
||||
rcgen = "0.14"
|
||||
sha2 = "0.11"
|
||||
zip = "8.6" # inbuxa: reading legal hold exports
|
||||
time = "0.3"
|
||||
testcontainers = { version = "0.28", features = ["reusable-containers"] }
|
||||
rust-s3 = { version = "0.37", default-features = false, features = ["tokio-rustls-tls"] }
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
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")
|
||||
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
|
||||
# same id doesn't start with legacy protocols off.
|
||||
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")
|
||||
|
||||
|
||||
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):
|
||||
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"],
|
||||
"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).
|
||||
tenant_checks(admin, admin_pw, account)
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
@@ -174,7 +176,8 @@ async fn report_dmarc() {
|
||||
let source_ip = record.source_ip().unwrap();
|
||||
if source_ip == "192.168.1.2".parse::<IpAddr>().unwrap() {
|
||||
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.header_from(), "[email protected]");
|
||||
assert_eq!(record.envelope_to().unwrap(), "[email protected]");
|
||||
|
||||
@@ -436,6 +436,96 @@ pub async fn test(test: &mut TestServer) {
|
||||
names.sort_unstable();
|
||||
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
|
||||
.hold_call("x:ArchivedItem/set", json!({"destroy": [item_id]}))
|
||||
.await;
|
||||
@@ -470,6 +560,16 @@ pub async fn test(test: &mut TestServer) {
|
||||
.await;
|
||||
let item = archived(frozen.archived_items().await);
|
||||
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 grace = chrono::Utc::now() + chrono::Duration::days(29);
|
||||
assert!(
|
||||
@@ -574,6 +674,22 @@ pub async fn test(test: &mut TestServer) {
|
||||
for reason in ["Counsel's letter", "Counsel widened the matter", "Matter settled"] {
|
||||
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
|
||||
let list = records["list"].as_array().cloned().unwrap_or_default();
|
||||
let release = list
|
||||
|
||||
Reference in New Issue
Block a user